Compare commits

..
Author SHA1 Message Date
fkwpandClaude Fable 5.1 89856df37f Add the FEATURES_SPEC process for turning explorations into specs and PRs
FEATURES_SPEC/AGENTS.md describes how an exploration is distilled into a
feature spec and how a spec is turned into PRs against a current main; the
product spec template is the sidecar a brainstorming session fills in.

The folder is excluded from oxfmt: a product spec is immutable once a spec
leaves draft, and the process documents are prose in their own style.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 15:28:39 +02:00
fkwpandClaude Opus 5 d3a2f5deda Add AGENTS.md documenting the repo's conventions
Codifies what is currently tribal knowledge or only discoverable by reading CI
config: the view model / view contract, the rule that nothing reads the page,
Clean Code ordering, the three test layers, and reuse-before-you-build.

The root AGENTS.md is the always-loaded minimum and routes to docs/agents/, so
an agent loads only the detail its task needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 15:28:39 +02:00
51 changed files with 1298 additions and 856 deletions
+1 -1
View File
@@ -2,5 +2,5 @@
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": ["pnpm-lock.yaml", "node_modules", "dist"]
"ignorePatterns": ["pnpm-lock.yaml", "node_modules", "dist", "FEATURES_SPEC"]
}
+2 -4
View File
@@ -16,8 +16,8 @@ host's page. It is the MatrixRTC reference implementation.
`@media`, global `i18next` or the `widget` global — take it from a provider.
- The change works standalone, as a widget and as a component. Say what you checked.
- Every gate below is green before you push.
- Hand off after the first implementation, before the quality pass. Then commit on
the user's word where the setup allows it, or hand them the message.
- Hand off after the first implementation, before the quality pass. Commit when the
user confirms direction, not before.
## Read before you
@@ -48,7 +48,5 @@ pnpm i18n:check
- View model + marble tests, thin view, a story per state, an e2e spec.
- Any new shared component called out explicitly, with why nothing existing fit.
- Template filled for real: what, why, before/after screenshots, repro steps.
- Anything that redraws continuously — an animation frame, a stream — says what it
costs, or better, counts the redraws in a test.
- Branch `<handle>/<topic>`. Plain imperative commit subjects, no prefixes.
- Once review starts, fix forward. Never force-push a regeneration over a review.
+415
View File
@@ -0,0 +1,415 @@
# FEATURE_SPEC — Agent Instructions
This folder holds **feature specs distilled from experiments**. A spec is the durable
artifact of an exploration; the exploration branch is not. Each spec must contain
enough information for an agent to produce mergeable PRs against a *current* `main`
without access to the original branch.
## What this folder is not
- Not a design-doc archive. Specs that are not intended to be implemented do not
belong here.
- Not a place for narrative. Prose that does not constrain the implementation or its
verification should be cut.
- Not a substitute for the PR description. The spec explains *why* and *what must be
true*; the PR explains *this diff*.
## File naming
`YYYY-MM_Snake_Case_Title.md` — e.g. `2026-09_Raise_Hand_Improvements.md`
The filename is immutable once committed. Renames break links from PRs, issues and
the drift log. If scope changes fundamentally, write a new spec and set
`supersedes:` / `superseded_by:`.
A sidecar `YYYY-MM_Snake_Case_Title.product.md` holds the product spec — user stories,
functional requirements, success criteria. Copy `_TEMPLATE.product.md`. Product owns
it; see "Brainstorming: the product spec".
An optional sidecar `YYYY-MM_Snake_Case_Title.notes.md` holds lessons learned,
narrative, dead-end logs and anything else that is worth keeping but does not
constrain the implementation. Rules:
- The sidecar is **never load-bearing**. Agents do not read it to derive
implementation, and nothing in it may contradict or extend the spec. If a note
constrains the work, it belongs in `## Decisions`, `## Rejected alternatives` or
`## Measurements` instead.
- Agents may append to it (an observation from a drift check, a wrong turn during
implementation) and must never rewrite or delete existing content.
- An agent that is `blocked` may read the sidecar to draft an open question, but
quotes what it found rather than acting on it.
- No sidecar is required. An empty one is worse than none.
## Style: facts only
Applies to the spec file only. The product spec is prose by design and the notes
sidecar is unconstrained. The spec is re-read on every regeneration, where prose costs context
and dilutes the contract, so:
- Bullets, not paragraphs. Declarative present tense. No motivation, no background,
no summary of what the code currently does beyond locating the change.
- Name things instead of describing them — a path, a symbol, an event type, a command,
an event id. A reference is shorter and more precise than a sentence about it. Which
kind of reference is allowed depends on the section; see "Reference durability".
- One line per decision plus at most one sentence of rationale. If the rationale needs
more, the extra goes in the sidecar.
- No judgement adjectives (clean, elegant, proper, obviously) and no hedging (should
probably, might be nice). Either it is a constraint or it is not in the spec.
- `## Problem` is at most five bullets.
- Target under 200 lines for the whole spec; over 300 lines, split the feature or move
material to the sidecar.
The test for any line: does it constrain the implementation, constrain its
verification, or record a measured fact? If not, delete it or move it to the sidecar.
## Reference durability
The spec must stay implementable against a `main` nobody has seen yet, so which
references are allowed depends on how fast they rot. Most to least durable:
1. Protocol identifiers — `m.reaction`, `m.call.member`, `m.relates_to`, a reaction
key. MSC-bound; effectively permanent.
2. Observable behaviour. Permanent.
3. Concept names — membership, participation, session.
4. Test names. Survive file moves and renames, because the test moves with the code.
5. Exported symbols. Survive refactors sometimes.
6. File paths. Rot fast.
7. Line numbers. Never.
Per section:
- `## Problem`, `## Measurements`, `## Slicing plan`, `## Drift log` — any reference,
paths and internal symbols included. These describe the codebase **as of `base`**. A
reference going stale here is a finding for the drift check, not a defect, and
removing the concrete references would leave the drift check with nothing to verify.
- `## Decisions`, `## Acceptance criteria` — levels 1 to 4 only. No file paths, no
internal symbols. This is the part that has to hold against a future `main`.
- An acceptance criterion that is not user-observable is tagged `[internal]` and
anchored by **its test name**, not by the symbol under test. The test name moves with
a refactor; the symbol does not.
`[internal]` ACs are legitimate and expected: an invariant that no current consumer can
observe is exactly what protects the consumer that does not exist yet. Tagging them
keeps visible which ACs defend a product requirement and which defend a code
invariant.
## Required frontmatter
```yaml
---
status: draft # see state machine below
blocked_from: null # state to return to when leaving `blocked`
title: Raise hand improvements
owner: <human handle> # a human, always
signoff: # join condition for leaving exploration.signoff
eng: pending
design: pending
product: pending
experiment_ref: <branch name or commit sha of the spike, if any>
base: <sha of main the spec was validated against>
repos: [matrix-js-sdk, element-call] # in dependency order
specs: [MSC4143] # protocol documents touched, if any
supersedes: []
superseded_by: null
---
```
`base` is mandatory and is what makes drift detectable. `owner` is a human even when
every section was drafted by an agent.
## Brainstorming: the product spec
The usual entry point is a human handing an agent a one-line feature idea. The output
of that session is `<stem>.product.md`, not code and not a feature spec. Copy
`_TEMPLATE.product.md` and fill it.
Ownership: an agent may create and fill this file during `brainstorm`. From `draft`
onwards **product owns it and agents do not edit it** — not to fix wording, not to
resolve a marker, not to reconcile it with the code. Agents raise mismatches in the
feature spec's `## Open questions`.
If product's source of truth lives outside the repo (a tracker, a design doc), put its
URL in the header `**Source**:` line and keep the requirement text and ids in the repo
anyway. The ids are what the feature spec references; a link alone cannot be traced.
Agent behaviour during the session:
- **Ask, do not assume.** An unanswered question is recorded as
`[NEEDS CLARIFICATION: <the question>]`, inline where the answer would go. Filling a
gap with a plausible guess is the main failure mode of this state — the marker is a
deliverable, not a defect.
- Ask in one round of at most five questions, ordered by how much the answer changes
the shape of the feature. Do not interview.
- **No implementation in the product spec.** No file paths, symbols, libraries, event
types, API shapes or framework names. If a requirement cannot be stated without them,
it is not a product requirement.
- Every user story must be independently deployable and demonstrable on its own. A
story that only makes sense once another story ships is not a story; merge it.
- More than four priority levels means more than one feature. Split before `draft`.
- Never invent an SC number. Ask for the target, or mark it
`[NEEDS CLARIFICATION]`.
For protocol and infrastructure work, success criteria may be expressed as observable
client behaviour or a wire-level invariant ("a client joining mid-session converges
within one sync"), but still never as a code path or an internal API.
`FR-###` and `SC-###` ids are immutable once assigned. Product may revise the document
at any time, including after `in_production`, but a changed requirement gets a **new
id** and the old one is marked superseded — never edited in place. Renumbering silently
breaks every AC that references it, and there is no way to detect that afterwards.
A new or changed FR after `implement` does not amend the current spec's scope. It is
either a new feature spec or the next iteration; the mapping table simply shows an
unmapped FR, which is the intended signal.
## Traceability
The product spec states intent; the feature spec states what must be true and how it is
checked. They are kept in sync by ids, not by duplication.
- Every `FR-###` maps to at least one acceptance criterion. Tag the AC with its
origin: `AC2 [FR-003] — <observable behaviour>`.
- Every `SC-###` maps to an entry in `## Measurements` with a method. An SC with no
measurement blocks `exploration.signoff`.
- Every user story maps to at least one slice. **Slice order follows story priority**
(P1 first) unless a technical dependency overrides it, and the override is recorded
in the slicing plan with its reason.
- An AC with no `FR` origin is allowed and expected — implementation-level invariants
have no counterpart in the product spec. The converse is not: an unmapped FR is an
incomplete spec.
- Agents do not resolve a mismatch between the two. The product spec wins on intent,
the feature spec wins on verification; a genuine contradiction is `blocked`.
## Status state machine
Transitions are explicit; an agent may perform only the ones marked (agent).
```
brainstorm ─► draft ─► exploration ─► implement ─► qa ─► in_production
│ │ │
└──────────────┴────────┴──► blocked ──► (blocked_from)
│
└──► abandoned
```
Exploration has sub-states, written dotted in `status`:
```
exploration.scoping ─► exploration.iterating ─► exploration.signoff
▲ │ │
└───────────────────────┴──────────────────────┘
```
- `brainstorm` → `draft`: human. Requires `<stem>.product.md` to exist with at least one
P1 story carrying acceptance scenarios, every FR and SC id assigned, and every
remaining `[NEEDS CLARIFICATION]` copied into the feature spec's `## Open questions`.
- `draft` → `exploration.scoping`: human. Requires `## Problem` and `## Out of scope`.
Scoping before the branch exists is what keeps exploration from becoming the
long-lived branch this folder replaces.
- `exploration.scoping` → `.iterating`: human. `experiment_ref` must name the branch.
- `.iterating` → `.signoff`: human. Requires `## Decisions`, `## Acceptance criteria`,
`## Rejected alternatives` and `## Measurements` to be complete, no
`[NEEDS CLARIFICATION]` left in the product spec, and full traceability (below).
- `.signoff` → `implement`: **only when all three sign-offs in `signoff:` are `ok`.**
This is a join, not a sequence — a single reviewer cannot advance it. On entering
`implement`, the exploration branch stops being a base: it is reference material,
and implementation branches off current `main` (see "Turning a spec into PRs").
- `.signoff` → `.iterating`: human, on any sign-off returning changes. Reset the
rejecting role to `pending`; leave the others as they are.
- `implement` → `qa`: (agent) when every acceptance criterion is covered, as recorded
in `## PRs`, and the feature flag is enabled in the QA environment.
- `qa` → `in_production`: human. The flag is on by default in production.
- `qa` → `implement`: (agent) when a QA finding needs code. See "QA feedback".
- any active state → `blocked`: (agent) when drift or an unresolved question prevents
progress. Requires a drift-log entry and `blocked_from: <state>`. Do not guess and
continue. Leaving `blocked` returns to `blocked_from`, never to a fixed state.
- any state → `abandoned`: human only.
`in_production` is terminal. A production regression does not reopen the spec: it
either is an ordinary bug fix with no spec, or it is a new spec. The acceptance
criteria remain as the regression contract (see "QA feedback").
## QA feedback
The point of the `qa` state is not to record that testing happened. It is to make the
spec a better regeneration source than it was before implementation. So:
- Every reproduced QA finding becomes **either** a new acceptance criterion **or** a
new entry in `## Rejected alternatives`. Never a prose note, never only a bug
tracker link.
- (agent) may **append** ACs during `qa`, with provenance:
`AC7 [qa: <issue ref>] — <observable behaviour>` plus its check. Appending is
allowed; rewriting or reordering existing ACs is not.
- A finding that invalidates a decision does not become an AC. It sets `blocked` — a
decision is human-owned in every state.
- A finding that is out of scope goes to `## Out of scope` with its issue ref, so the
next regeneration does not treat it as a gap.
This also applies after `in_production`: a production bug that reveals a missing AC is
appended with `[prod: <ref>]` provenance without any state change.
## Required sections
Order matters — agents read top-down and the first three sections are the contract.
1. **`## Problem`** — up to five bullets. What breaks today, for whom, observably,
with the file or symbol where it breaks.
2. **`## Decisions`** — the choices the exploration settled, each with a one-line
rationale. **Human-owned. An agent must never add, remove or reword a decision.**
If a decision is missing, set `blocked` and ask.
3. **`## Acceptance criteria`** — the load-bearing section. Each item must be
mechanically checkable and name its check:
- `AC1 [FR-002] — <observable behaviour>` / `check: yarn vitest run src/reactions/ReactionsReader.test.tsx -t "backfill"`
- `AC2 [FR-002] — <observable behaviour>` / `check: manual, <exact steps>`
- `AC3 [internal] — <invariant>` / `check: yarn vitest run <file> -t "<test name>"`
An acceptance criterion without a check is a wish; reject the spec (`blocked`).
4. **`## Rejected alternatives`** — what the experiment tried and why it failed,
including measurements. This exists so regeneration does not re-walk dead ends. If
this section is empty for a spec with an `experiment_ref`, the spec is incomplete.
5. **`## Measurements`** — numbers from the experiment with the method used to obtain
them (command, hardware, load profile). Numbers without a method are not usable as
regression baselines and must be marked `unverified`. A baseline states its direction: a
count that unrelated work grows — tests passing, test files — is a floor (`≥ n`); a
cost — latency, CPU, bundle size — is a ceiling (`≤ n`). An exact count is stale as
soon as `main` moves and is not a drift finding.
6. **`## Out of scope`** — explicit non-goals, so agents stop expanding the diff.
7. **`## Slicing plan`** — ordered, independently mergeable PRs (see below).
8. **`## Drift log`** — append-only, agent-written.
9. **`## PRs`** — append-only list of `#<n> — <state> — <ACs covered>`.
Optional: `## Open questions` (blocks `exploration.signoff`), `## Migration / compat` (mandatory
whenever wire format, event schema, or a published API changes).
## Turning a spec into PRs
Run these steps in order. Do not skip step 2.
1. **Fetch and pin.** `git fetch origin` and record `origin/main`'s sha as `head`.
Never start from the exploration branch, and never rebase it — the branch is
reference material, not a base.
2. **Drift check** against `base..head`:
- Do all files, symbols and modules named in the spec still exist?
- Have the invariants in `## Decisions` been contradicted by changes in main?
- Do the commands in `## Acceptance criteria` still run?
Write the result as a drift-log entry. On a contradicted decision: set `blocked`,
do not implement.
3. **Re-derive the slicing plan** if drift changed it. Each slice must:
- build and pass CI on its own,
- be reviewable in one sitting (target < 400 changed lines, hard stop at 800 —
split further rather than exceeding it),
- map to at least one acceptance criterion, and
- land behind a flag or be inert if it is not the last slice of a user-visible
change.
4. **Implement one slice at a time**, branch `spec/<spec-slug>/<n>-<short>` off the
pinned `head`. Tests for the slice's ACs go in the same PR as the code. A PR merges
only when every check of every AC it lists passes on that PR: automated checks in
CI, manual checks performed by a human reviewer with the result recorded in the PR
description. An AC is *covered* once such a PR has merged.
5. **Open the PR** linking the spec path and listing the ACs it covers. Update
`## PRs` and `status`.
6. **Do not touch merged or reviewed slices.** See the regeneration rule.
## The regeneration rule
Regeneration from spec is only allowed **before a human has reviewed the PR**.
Once review has started, the PR is a shared artifact: rebase, fix forward, push
incremental commits. Regenerating discards the reviewer's context and silently
invalidates their approvals. If regeneration is unavoidable after review, close the
old PR with an explanation and open a new one — never force-push a fresh generation
over a reviewed branch.
## Cross-repo and protocol work
When `repos` has more than one entry, PRs are opened in `repos` order and each PR
body states which upstream PR it depends on. Merging out of order is a spec
violation, not an optimisation.
When `specs` is non-empty:
- the implementation PR references the section of the MSC it implements,
- unmerged MSCs require the implementation to be feature-flagged and documented as
unstable (unstable prefixes, not stable identifiers), and
- any deviation from the MSC text is recorded in `## Drift log`, not silently
resolved in favour of the code.
## Drift-log format
Append-only, newest last:
```
### 2026-09-14 — drift check (base 4f2a1c9 → head 9b17e02)
- `ReactionsReader.getLastReactionEvent` now takes a `CallMembership` instead of an
event id → AC2 and AC3 check commands still valid, slice 3 needs rework
- BLOCKING: decision D2 assumes a membership event change may keep a hand raised; main
now clears all reaction relations on membership replacement. Needs owner decision.
```
Drift entries are never rewritten or deleted, including entries that were later
resolved. The log is the record of why the implementation diverged from the
exploration.
## Hard rules
- Never fill a `[NEEDS CLARIFICATION]` marker with a guess, in any state.
- Never put file paths, symbols or library names in the product spec.
- Never reference a file path or an internal symbol in `## Decisions` or
`## Acceptance criteria`. Behaviour, protocol identifiers, concepts and test names
only.
- Never edit `<stem>.product.md` after `draft`, and never renumber an FR or SC id.
- Never add a line that does not constrain the implementation, constrain its
verification, or record a measured fact. It goes in the sidecar or nowhere.
- Never modify `## Decisions`, `## Problem`, or `## Acceptance criteria`. These are
the human contract. Propose changes in `## Open questions` and set `blocked`.
- Never invent measurements or acceptance checks. Absent is better than plausible.
- Never widen scope past `## Out of scope`, even when the fix looks trivial.
- Never open a PR while `status` is `draft`, any `exploration.*`, or `blocked`.
- Never delete a spec file. Terminal states are `in_production` or `abandoned`.
- If the spec and current `main` disagree about how the system works, **main wins as
description, the spec wins as intent** — record the difference and stop.
## Spec template
```markdown
---
status: draft
blocked_from: null
title:
owner:
signoff:
eng: pending
design: pending
product: pending
experiment_ref:
base:
repos: []
specs: []
supersedes: []
superseded_by: null
---
## Problem
## Decisions
- D1 — <decision> — <why>
## Acceptance criteria
- AC1 [FR-###] — <observable behaviour>
- check: <command or exact manual steps>
- AC2 [internal] — <invariant>
- check: <command, anchored by test name>
## Rejected alternatives
- <approach> — <why it failed> — <evidence>
## Measurements
- <metric>: <value> (method: <command / setup>)
## Out of scope
## Slicing plan
1. <slice> — covers AC1 — depends on: none
## Drift log
## PRs
```
+117
View File
@@ -0,0 +1,117 @@
# Feature Specification: [FEATURE NAME]
**Feature Branch**: `[###-feature-name]`
**Created**: [DATE]
**Feature Spec**: `[YYYY-MM_Title.md]` <!-- status lives in the feature spec's frontmatter, not here -->
**Owner**: [product handle]
**Source**: [URL of the tracker/doc this mirrors, or "this file"]
**Input**: User description: "$ARGUMENTS"
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - [Brief Title] (Priority: P1)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
### User Story 2 - [Brief Title] (Priority: P2)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
### User Story 3 - [Brief Title] (Priority: P3)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
[Add more user stories as needed, each with an assigned priority]
### Edge Cases
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right edge cases.
-->
- What happens when [boundary condition]?
- How does system handle [error scenario]?
## Requirements *(mandatory)*
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
-->
### Functional Requirements
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
*Example of marking unclear requirements:*
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
### Key Entities *(include if feature involves data)*
- **[Entity 1]**: [What it represents, key attributes without implementation]
- **[Entity 2]**: [What it represents, relationships to other entities]
## Success Criteria *(mandatory)*
<!--
ACTION REQUIRED: Define measurable success criteria.
These must be technology-agnostic and measurable.
-->
### Measurable Outcomes
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
-17
View File
@@ -1,17 +0,0 @@
id: "lk-jwt-service-othersite"
as_token: "ec_dev_as_token_othersite_0123456789ab"
hs_token: "ec_dev_hs_token_othersite_0123456789ab"
sender_localpart: "_lk_jwt_service"
namespaces:
users:
- exclusive: false
regex: "@.*:homeserver-1" # Cover all local users
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
url: "http://auth-server-1:16080"
# Enable membership look-ups via /is_joined.
io.element.msc4502.scopes:
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
# service.
io.element.msc4512.proxy_prefix: "rtc/livekit"
io.element.msc4512.proxy_url: "http://auth-server-1:16080"
-17
View File
@@ -1,17 +0,0 @@
id: "lk-jwt-service"
as_token: "ec_dev_as_token_main_0123456789ab"
hs_token: "ec_dev_hs_token_main_0123456789ab"
sender_localpart: "_lk_jwt_service"
namespaces:
users:
- exclusive: false
regex: "@.*:homeserver" # Cover all local users
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
url: "http://auth-server:6080"
# Enable membership look-ups via /is_joined.
io.element.msc4502.scopes:
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
# service.
io.element.msc4512.proxy_prefix: "rtc/livekit"
io.element.msc4512.proxy_url: "http://auth-server:6080"
-7
View File
@@ -42,13 +42,6 @@ experimental_features:
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# Enable membership look-up via /is_joined.
msc4502_enabled: true
# Enable C-S & S-S request proxying for application services.
msc4512_enabled: true
app_service_config_files:
- /data/cfg/app-service.yaml
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
-7
View File
@@ -42,13 +42,6 @@ experimental_features:
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# Enable membership look-up via /is_joined.
msc4502_enabled: true
# Enable C-S & S-S request proxying for application services.
msc4512_enabled: true
app_service_config_files:
- /data/cfg/app-service.yaml
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
@@ -42,13 +42,6 @@ experimental_features:
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# Enable membership look-up via /is_joined.
msc4502_enabled: true
# Enable C-S & S-S request proxying for application services.
msc4512_enabled: true
app_service_config_files:
- /data/cfg/app-service.yaml
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
-7
View File
@@ -42,13 +42,6 @@ experimental_features:
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# Enable membership look-up via /is_joined.
msc4502_enabled: true
# Enable C-S & S-S request proxying for application services.
msc4512_enabled: true
app_service_config_files:
- /data/cfg/app-service.yaml
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
+4 -28
View File
@@ -3,7 +3,7 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
pull_policy: always
hostname: auth-server
environment:
@@ -15,14 +15,6 @@ services:
# a self-signed certificate
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
# Registers this instance as an application service on the main homeserver
- LIVEKIT_AS_REGISTRATION_FILE=/etc/lk-jwt-service/app-service.yaml
- LIVEKIT_HS_SERVER_NAME=synapse.m.localhost
# Neither homeserver serves /.well-known/matrix/client, so the C-S API
# location has to be given explicitly.
- LIVEKIT_CS_API_URL_OVERRIDES=synapse.m.localhost=http://homeserver:8008,synapse.othersite.m.localhost=http://homeserver-1:18008
volumes:
- ./backend/app-service.yaml:/etc/lk-jwt-service/app-service.yaml:Z
deploy:
restart_policy:
condition: on-failure
@@ -33,7 +25,7 @@ services:
- ecbackend
auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
pull_policy: always
hostname: auth-server-1
environment:
@@ -45,14 +37,6 @@ services:
# a self-signed certificate
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
# Registers this instance as an application service on the other homeserver.
- LIVEKIT_AS_REGISTRATION_FILE=/etc/lk-jwt-service/app-service.yaml
- LIVEKIT_HS_SERVER_NAME=synapse.othersite.m.localhost
# Neither homeserver serves /.well-known/matrix/client, so the C-S API
# location has to be given explicitly.
- LIVEKIT_CS_API_URL_OVERRIDES=synapse.m.localhost=http://homeserver:8008,synapse.othersite.m.localhost=http://homeserver-1:18008
volumes:
- ./backend/app-service-othersite.yaml:/etc/lk-jwt-service/app-service.yaml:Z
deploy:
restart_policy:
condition: on-failure
@@ -110,10 +94,7 @@ services:
synapse:
hostname: homeserver
# develop (not latest) is required for application service C-S/S-S proxying
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
# release yet.
image: ghcr.io/element-hq/synapse:develop
image: ghcr.io/element-hq/synapse:latest
pull_policy: always
environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
@@ -126,16 +107,12 @@ services:
volumes:
- ./backend/synapse_tmp:/data:Z
- ./backend/dev_homeserver.yaml:/data/cfg/homeserver.yaml:Z
- ./backend/app-service.yaml:/data/cfg/app-service.yaml:Z
networks:
- ecbackend
synapse-1:
hostname: homeserver-1
# develop (not latest) is required for application service C-S/S-S proxying
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
# release yet.
image: ghcr.io/element-hq/synapse:develop
image: ghcr.io/element-hq/synapse:latest
pull_policy: always
environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
@@ -148,7 +125,6 @@ services:
volumes:
- ./backend/synapse_tmp_othersite:/data:Z
- ./backend/dev_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
- ./backend/app-service-othersite.yaml:/data/cfg/app-service.yaml:Z
networks:
- ecbackend
+4 -10
View File
@@ -3,23 +3,17 @@
services:
synapse:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
# develop (not latest) is required for application service C-S/S-S proxying
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
# release yet.
image: ghcr.io/element-hq/synapse:develop@sha256:337921fd22be310d453344b2265f6c9942315446595df3cf81866f71ca2055a7
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
volumes:
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
synapse-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
# develop (not latest) is required for application service C-S/S-S proxying
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
# release yet.
image: ghcr.io/element-hq/synapse:develop@sha256:337921fd22be310d453344b2265f6c9942315446595df3cf81866f71ca2055a7
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
volumes:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
-2
View File
@@ -12,8 +12,6 @@ lines a PR touches.
- Reuse the factories in `src/utils/test.ts` — `mockRemoteParticipant`,
`mockMatrixRoom`, `mockLivekitRoom`, `MockRTCSession`. Hand-rolled mocks drift.
- Snapshots live in `__snapshots__/`; update with `pnpm test <Name> -u`.
- How often something redraws is testable: drive the frames and count commits, not
render calls. An effect with no dependency array runs once per commit.
- `component/**/*.test.ts` runs in the same jsdom project as `src`.
## Storybook
+5 -7
View File
@@ -39,12 +39,10 @@ Git-ignored. One kebab-case subfolder per task, matching the branch topic:
## Commit and PR readiness
- Commit once the user confirms direction, or asks for one. Not before. Where the
setup has no git identity, a sandbox included, write the message to
`commit-msg.txt` and hand it over instead.
- Ready means every gate in [AGENTS.md](../../AGENTS.md#gates) is green and the
change is covered at the layers [testing.md](./testing.md) asks for. Read the diff
against [code-style.md](./code-style.md) first.
- Re-run the whole checklist after any fix. Only on green, either way.
- Commit once the user confirms direction, or asks for one. Not before.
- Before committing, every gate in [AGENTS.md](../../AGENTS.md#gates) is green and
the change is covered at the layers [testing.md](./testing.md) asks for. Read the
diff against [code-style.md](./code-style.md).
- Re-run the whole checklist after any fix. Commit only on green.
- Once a human has started reviewing, fix forward — never force-push a
regeneration over a review in progress.
+1 -1
View File
@@ -83,7 +83,7 @@
"@types/sdp-transform": "^2.4.5",
"@typescript-eslint/utils": "^8.61.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^11.0.0",
"@vector-im/compound-design-tokens": "^10.0.0",
"@vector-im/compound-web": "^10.0.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
+4 -13
View File
@@ -109,15 +109,10 @@ export const widgetTest = test.extend<MyFixtures>({
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
// Accept the invite
await TestHelpers.closeReleaseAnnouncement(
ewPage2,
"Introducing Sections",
);
await TestHelpers.expandAllSections(ewPage2);
await expect(
TestHelpers.roomListItem(ewPage2, "Welcome Room"),
ewPage2.getByRole("option", { name: "Welcome Room" }),
).toBeVisible();
await TestHelpers.roomListItem(ewPage2, "Welcome Room").click();
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
await ewPage2.getByRole("button", { name: "Accept" }).click();
await expect(
ewPage2
@@ -157,12 +152,8 @@ export const widgetTest = test.extend<MyFixtures>({
).toBeVisible();
// Accept the DM invite from brooks
await TestHelpers.closeReleaseAnnouncement(
ewPage2,
"Introducing Sections",
);
await TestHelpers.expandAllSections(ewPage2);
await TestHelpers.roomListItem(ewPage2, brooksDisplayName).click();
// This how playwright record selects the DM invite in the room list
await ewPage2.getByRole("button", { name: "Open room" }).click();
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
}
-8
View File
@@ -7,8 +7,6 @@ Please see LICENSE in the repository root for full details.
import { expect, test } from "@playwright/test";
import { SpaHelpers } from "./spa-helpers";
// Skip test for Firefox, due to page.keyboard.press("Tab") not reliable on headless mode
test.skip(
({ browserName }) => browserName === "firefox",
@@ -19,12 +17,6 @@ test("can only interact with header and footer while reconnecting", async ({
page,
}) => {
await page.goto("/");
// The reconnecting state is entered via the probablyLeft timer, which
// mirrors the delayed leave event's timeout. With delegation that timeout
// is one hour, putting it out of reach of the clock fast-forward below.
// Keep the leave client-managed so its short timeout applies.
await SpaHelpers.disableLeaveDelegation(page);
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("Test call");
await page.getByTestId("home_displayName").click();
+1 -11
View File
@@ -19,7 +19,6 @@ async function setupTwoUserSpaCall(
browser: Browser,
page: Page,
browserName: string,
opts: { disableGuestLeaveDelegation?: boolean } = {},
): Promise<{ guestPage: Page }> {
test.skip(
browserName === "firefox",
@@ -53,9 +52,6 @@ async function setupTwoUserSpaCall(
await guestPage.goto("/");
if (opts.disableGuestLeaveDelegation)
await SpaHelpers.disableLeaveDelegation(guestPage);
let pevaraHasSentStickyEvent = false;
const pevaraResolver = Promise.withResolvers<void>();
@@ -106,13 +102,7 @@ test("One to One rejoin after improper leave does not crash EC", async ({
page,
browserName,
}) => {
// With delegation, the backend sends the guest's delayed leave event within
// moments of the improper leave, so the stale membership this test needs
// would be cleaned up before the rejoin. Keep the guest's leave
// client-managed so the stale membership lingers.
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName, {
disableGuestLeaveDelegation: true,
});
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName);
await SpaHelpers.expectVideoTilesCount(page, 2);
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
-19
View File
@@ -111,24 +111,6 @@ async function setRtcModeFromSettings(
await page.getByTestId("modal_close").click();
}
/**
* Makes the delayed-leave delegation support probes fail so that the client
* manages its delayed leave event itself instead of delegating it to the
* backend.
*
* Must be installed before the page joins a call.
*/
async function disableLeaveDelegation(page: Page): Promise<void> {
// Covers both the transport probe (<livekit_service_url>/delegate_delayed_leave)
// and the homeserver probe (MSC4195, .../rtc/livekit/delegate_delayed_leave).
await page.route("**/delegate_delayed_leave", async (route) =>
route.fulfill({
status: 404,
headers: { "Access-Control-Allow-Origin": "*" },
}),
);
}
/**
* Expect a certain number of video tiles to be present and visible.
*/
@@ -151,6 +133,5 @@ export const SpaHelpers = {
createCall,
getCallInviteLink,
joinCallFromInviteLink,
disableLeaveDelegation,
expectVideoTilesCount,
};
+4 -27
View File
@@ -11,7 +11,6 @@ import {
type JSHandle,
type Page,
type FrameLocator,
type Locator,
} from "@playwright/test";
import { type MatrixClient } from "matrix-js-sdk";
@@ -204,16 +203,6 @@ export class TestHelpers {
}
}
public static async expandAllSections(page: Page): Promise<void> {
try {
await page
.getByRole("button", { name: "Expand all sections" })
.click({ timeout: 2000 });
} catch {
// Already expanded or button not present
}
}
public static async createRoom(
name: string,
page: Page,
@@ -264,9 +253,9 @@ export class TestHelpers {
roomName: string,
page: Page,
): Promise<void> {
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
await TestHelpers.expandAllSections(page);
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
await page.getByRole("option", { name: roomName }).click({
timeout: 10000,
});
await page.getByRole("button", { name: "Accept" }).click({
timeout: 5000,
});
@@ -361,17 +350,6 @@ export class TestHelpers {
.click();
}
/**
* Locates a room in the room list by its name.
*
* Matches on the aria-label prefix because the item's role differs between
* the flat room list (`option`) and the sectioned room list (`button`), and
* the label may carry a suffix such as " invitation.".
*/
public static roomListItem(page: Page, roomName: string): Locator {
return page.locator(`[aria-label^="Open room ${roomName}"]`);
}
/**
* Switches to a room in the room list by its name.
* @param page - The EW page
@@ -381,8 +359,7 @@ export class TestHelpers {
page: Page,
roomName: string,
): Promise<void> {
await TestHelpers.expandAllSections(page);
await TestHelpers.roomListItem(page, roomName).click();
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
}
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
+22 -22
View File
@@ -39,13 +39,13 @@ importers:
version: 11.7.12
'@livekit/components-core':
specifier: ^0.12.0
version: 0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
version: 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
'@livekit/components-react':
specifier: ^2.0.0
version: 2.9.24(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
version: 2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
'@livekit/track-processors':
specifier: ^0.7.1
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))
'@mediapipe/tasks-vision':
specifier: ^0.10.18
version: 0.10.35
@@ -131,11 +131,11 @@ importers:
specifier: ^10.2.11
version: 10.3.1(react@19.2.8)
'@vector-im/compound-design-tokens':
specifier: ^11.0.0
version: 11.0.0(@types/react@19.2.18)(react@19.2.8)
specifier: ^10.0.0
version: 10.2.4(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-web':
specifier: ^10.0.0
version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@vitejs/plugin-react':
specifier: ^6.0.2
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
@@ -183,7 +183,7 @@ importers:
version: 5.88.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(typescript@5.9.3)
livekit-client:
specifier: ^2.18.1
version: 2.22.3(@types/dom-mediacapture-record@1.0.22)
version: 2.22.2(@types/dom-mediacapture-record@1.0.22)
lodash-es:
specifier: ^4.17.21
version: 4.18.1
@@ -3229,8 +3229,8 @@ packages:
peerDependencies:
react: '>= 16.8.0'
'@vector-im/compound-design-tokens@11.0.0':
resolution: {integrity: sha512-pWz0Q67Xbo8gwzaTeFbTTcM6i29ju6PR7SqyuExr2nIV3j9K785cOInft8FA2CM7PEapkr6TRtJKR/4xtWh2RA==}
'@vector-im/compound-design-tokens@10.2.4':
resolution: {integrity: sha512-2aCjhSvJxktPWnvh5s9Xdc0Rd76AfHqbDbN4QjOxo2VIac6zYdLyQbxMUQ5ZeoR9G3vAxILd0H1UvApRfMilpQ==}
peerDependencies:
'@adobe/leonardo-contrast-colors': ^1.0.0
'@types/react': '*'
@@ -4605,8 +4605,8 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
livekit-client@2.22.3:
resolution: {integrity: sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==}
livekit-client@2.22.2:
resolution: {integrity: sha512-o3yLYoH2eHVR/DmIZzgC96FMvtR4+g/DjeqNwS5zp12EjqrqiRULxm6Oq/YsTE97WnA6fhy23+a2hGkbce5SPA==}
peerDependencies:
'@types/dom-mediacapture-record': ^1
@@ -7079,21 +7079,21 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
'@livekit/components-core@0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
'@livekit/components-core@0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
dependencies:
'@floating-ui/dom': 1.7.6
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
loglevel: 1.9.1
rxjs: 7.8.2
tslib: 2.8.1
'@livekit/components-react@2.9.24(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
'@livekit/components-react@2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
dependencies:
'@livekit/components-core': 0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
'@livekit/components-core': 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
clsx: 2.1.1
events: 3.3.0
jose: 6.2.9
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
tslib: 2.8.1
@@ -7105,11 +7105,11 @@ snapshots:
dependencies:
'@bufbuild/protobuf': 1.10.1
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))':
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))':
dependencies:
'@mediapipe/tasks-vision': 0.10.35
'@types/dom-mediacapture-transform': 0.1.11
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
@@ -8695,12 +8695,12 @@ snapshots:
'@use-gesture/core': 10.3.1
react: 19.2.8
'@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8)':
'@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8)':
optionalDependencies:
'@types/react': 19.2.18
react: 19.2.8
'@vector-im/compound-web@10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
'@vector-im/compound-web@10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -8712,7 +8712,7 @@ snapshots:
'@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-design-tokens': 11.0.0(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-design-tokens': 10.2.4(@types/react@19.2.18)(react@19.2.8)
classnames: 2.5.1
react: 19.2.8
vaul: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -10180,7 +10180,7 @@ snapshots:
lines-and-columns@1.2.4: {}
livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22):
livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22):
dependencies:
'@livekit/mutex': 1.1.1
'@livekit/protocol': 1.50.4
@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="_r_bh_"
aria-labelledby="_r_bb_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
-4
View File
@@ -25,7 +25,6 @@ import {
import {
setLogExtension as setLKLogExtension,
setLogLevel as setLKLogLevel,
LoggerNames as LKLoggerNames,
} from "livekit-client";
import { getUrlParams } from "./UrlParams";
@@ -227,9 +226,6 @@ export class Initializer {
enableExtendedLivekitLogs.value$.subscribe((enabled) => {
setLKLogLevel(enabled ? "trace" : "info");
// ICE candidate types and connection states are what you need to diagnose
// "could not establish pc connection", so always keep those in the rageshake
setLKLogLevel("debug", LKLoggerNames.ICE);
});
window.setLKLogLevel = setLKLogLevel;
-53
View File
@@ -24,12 +24,10 @@ import { testAudioContext } from "../useAudioContext.test";
import * as MediaDevicesContext from "../MediaDevicesContext";
import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
import {
mockLocalParticipant,
mockMediaDevices,
mockRemoteParticipant,
mockTrack,
} from "../utils/test";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { initializeWidget } from "../widget";
initializeWidget();
export const TestAudioContextConstructor = vi.fn(
@@ -133,57 +131,6 @@ it("should render for member", () => {
expect(queryAllByTestId("audio")).toHaveLength(1);
});
function spyOnWarn(): ReturnType<typeof vi.fn> {
const warn = vi.fn();
vi.spyOn(rootLogger, "getChild").mockReturnValue({
warn,
} as unknown as typeof rootLogger);
return warn;
}
it("should not render or warn for the local participant", () => {
const warn = spyOnWarn();
const local = mockLocalParticipant({ identity: "@alice:DEV0" });
vi.mocked(useTracks).mockReturnValue([mockTrack(local)]);
const { queryAllByTestId } = render(
<MediaDevicesProvider value={mockMediaDevices({})}>
<LivekitRoomAudioRenderer
validIdentities={[]}
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
url={""}
/>
</MediaDevicesProvider>,
);
expect(queryAllByTestId("audio")).toHaveLength(0);
expect(warn).not.toHaveBeenCalled();
});
it("should warn only once per unexpected participant", () => {
const warn = spyOnWarn();
const { rerender } = renderTestComponent(
[{ userId: "@bob", deviceId: "DEV0" }],
["@alice:DEV0"],
[
{
participantId: "@alice:DEV0",
kind: Track.Kind.Audio,
source: Track.Source.Microphone,
},
],
);
expect(warn).toHaveBeenCalledTimes(1);
rerender(
<MediaDevicesProvider value={mockMediaDevices({})}>
<LivekitRoomAudioRenderer
validIdentities={[]}
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
url={""}
/>
</MediaDevicesProvider>,
);
expect(warn).toHaveBeenCalledTimes(1);
});
it("should not render without member", () => {
const { container, queryAllByTestId } = renderTestComponent(
[{ userId: "@bob", deviceId: "DEV0" }],
+9 -17
View File
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { getTrackReferenceId } from "@livekit/components-core";
import { type Room as LivekitRoom } from "livekit-client";
import { type RemoteAudioTrack, Track } from "livekit-client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
useTracks,
AudioTrack,
@@ -60,9 +60,6 @@ export function LivekitRoomAudioRenderer({
muted,
}: MatrixAudioRendererProps): ReactNode {
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
// Identities we have already warned about, so that re-renders (which happen
// on every active speaker update) don't repeat the warning.
const warnedIdentities = useRef(new Set<string>());
const tracks = useTracks(
[
Track.Source.Microphone,
@@ -75,27 +72,22 @@ export function LivekitRoomAudioRenderer({
room: livekitRoom,
},
)
// Only keep remote audio tracks (we never render our own audio)
.filter(
(ref) =>
ref.publication.kind === Track.Kind.Audio && !ref.participant.isLocal,
)
// Only keep audio tracks
.filter((ref) => ref.publication.kind === Track.Kind.Audio)
// Only keep tracks from participants that are in the validIdentities list
.filter((ref) => {
const { identity } = ref.participant;
const isValid = validIdentities.includes(identity);
if (!isValid && !warnedIdentities.current.has(identity)) {
warnedIdentities.current.add(identity);
const isValid = validIdentities.includes(ref.participant.identity);
if (!isValid) {
// TODO make sure to also skip the warn logging for the local identity
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
logger.warn(
`Audio track ${identity} from ${url} has no matching matrix call member`,
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
`current members: ${validIdentities.join()}`,
`track will not get rendered`,
);
} else if (isValid) {
warnedIdentities.current.delete(identity);
return false;
}
return isValid;
return true;
});
// This component is also (in addition to the "only play audio for connected members" logic above)
-87
View File
@@ -1,87 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { type LocalVideoTrack } from "livekit-client";
import {
type BackgroundOptions,
type ProcessorWrapper,
} from "@livekit/track-processors";
import { applyProcessor, trackProcessorSync } from "./TrackProcessorContext";
import { constant } from "../state/Behavior";
import { flushPromises, testScope } from "../utils/test";
const processor = {} as ProcessorWrapper<BackgroundOptions>;
function mockTrack(
readyState: MediaStreamTrackState,
current?: ProcessorWrapper<BackgroundOptions>,
): LocalVideoTrack {
return {
mediaStreamTrack: { readyState },
getProcessor: vi.fn().mockReturnValue(current),
setProcessor: vi.fn().mockResolvedValue(undefined),
stopProcessor: vi.fn().mockResolvedValue(undefined),
} as unknown as LocalVideoTrack;
}
describe("applyProcessor", () => {
it("attaches the processor to a live track", () => {
const track = mockTrack("live");
applyProcessor(track, processor);
expect(track.setProcessor).toHaveBeenCalledWith(processor);
});
it("does not attach the processor to an ended track", () => {
const track = mockTrack("ended");
applyProcessor(track, processor);
expect(track.setProcessor).not.toHaveBeenCalled();
});
it("does not surface a rejected setProcessor", async () => {
const track = mockTrack("live");
vi.mocked(track.setProcessor).mockRejectedValue(
new TypeError("Input track cannot be ended"),
);
const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
applyProcessor(track, processor);
await flushPromises();
process.off("unhandledRejection", unhandled);
expect(unhandled).not.toHaveBeenCalled();
});
it("stops the processor when none is wanted", () => {
const track = mockTrack("live", processor);
applyProcessor(track, undefined);
expect(track.stopProcessor).toHaveBeenCalled();
});
it("does not surface a rejected stopProcessor", async () => {
const track = mockTrack("live", processor);
vi.mocked(track.stopProcessor).mockRejectedValue(new Error("nope"));
const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
applyProcessor(track, undefined);
await flushPromises();
process.off("unhandledRejection", unhandled);
expect(unhandled).not.toHaveBeenCalled();
});
});
describe("trackProcessorSync", () => {
it("applies the processor to the current track", () => {
const track = mockTrack("live");
trackProcessorSync(
testScope(),
constant(track),
constant({ supported: true, processor }),
);
expect(track.setProcessor).toHaveBeenCalledWith(processor);
});
});
+13 -31
View File
@@ -19,7 +19,6 @@ import {
useMemo,
} from "react";
import { type LocalVideoTrack } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { combineLatest, map, type Observable } from "rxjs";
import { useObservable } from "observable-hooks";
@@ -66,34 +65,6 @@ export function useTrackProcessorObservable$(): Observable<ProcessorState> {
return state$;
}
/**
* Attaches or detaches the processor so that the track matches the desired
* state, without throwing.
*/
export function applyProcessor(
videoTrack: LocalVideoTrack,
processor: ProcessorWrapper<BackgroundOptions> | undefined,
): void {
if (processor && !videoTrack.getProcessor()) {
// A MediaStreamTrackProcessor cannot be constructed on an ended track
// (e.g. the camera was stopped while the processor was being applied),
// and setProcessor rejects with a TypeError. The track is going away
// anyway, so there is nothing to attach to.
if (videoTrack.mediaStreamTrack.readyState === "ended") {
logger.debug("Not attaching video processor to an ended track");
return;
}
videoTrack.setProcessor(processor).catch((e) => {
logger.warn("Failed to attach video processor", e);
});
}
if (!processor && videoTrack.getProcessor()) {
videoTrack.stopProcessor().catch((e) => {
logger.warn("Failed to stop video processor", e);
});
}
}
/**
* Updates your video tracks to always use the given processor.
*/
@@ -107,7 +78,13 @@ export const trackProcessorSync = (
.subscribe(([videoTrack, processorState]) => {
if (!processorState) return;
if (!videoTrack) return;
applyProcessor(videoTrack, processorState.processor);
const { processor } = processorState;
if (processor && !videoTrack.getProcessor()) {
void videoTrack.setProcessor(processor);
}
if (!processor && videoTrack.getProcessor()) {
void videoTrack.stopProcessor();
}
});
};
@@ -117,7 +94,12 @@ export const useTrackProcessorSync = (
const { processor } = useTrackProcessor();
useEffect(() => {
if (!videoTrack) return;
applyProcessor(videoTrack, processor);
if (processor && !videoTrack.getProcessor()) {
void videoTrack.setProcessor(processor);
}
if (!processor && videoTrack.getProcessor()) {
void videoTrack.stopProcessor();
}
}, [processor, videoTrack]);
};
-11
View File
@@ -29,8 +29,6 @@ import waveSoundOgg from "../sound/reactions/wave.ogg?url";
import waveSoundMp3 from "../sound/reactions/wave.mp3?url";
import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url";
import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url";
import bellSoundOgg from "../sound/reactions/bell.ogg?url";
import bellSoundMp3 from "../sound/reactions/bell.mp3?url";
export const ElementCallReactionEventType = "io.element.call.reaction";
@@ -204,15 +202,6 @@ export const ReactionSet: ReactionOption[] = [
mp3: baduntssSoundMp3,
},
},
{
emoji: "🛎️",
name: "bell",
alias: ["ding"],
sound: {
ogg: bellSoundOgg,
mp3: bellSoundMp3,
},
},
];
export interface RaisedHandInfo {
-1
View File
@@ -10,7 +10,6 @@ The following sound effects have been licensed from Pixabay, under https://pixab
- `reactions/generic`
- `reactions/lightbulb`
- `reactions/party`
- `reactions/bell`
### Other
Binary file not shown.
Binary file not shown.
+1 -61
View File
@@ -10,7 +10,6 @@ import * as ComponentsCore from "@livekit/components-core";
import { ObservableScope } from "./ObservableScope";
import { AudioOutput } from "./MediaDevices";
import { audioOutput as audioOutputSetting } from "../settings/settings";
import { withTestScheduler } from "../utils/test";
const BT_SPEAKER = {
@@ -94,8 +93,6 @@ describe("AudioOutput Tests", () => {
beforeEach(() => {
testScope = new ObservableScope();
// Device preferences persist in localStorage across tests
audioOutputSetting.setValue(undefined);
});
afterEach(() => {
@@ -153,9 +150,7 @@ describe("AudioOutput Tests", () => {
expectObservable(audioOutput.selected$).toBe("abcde", {
a: undefined,
// No "default" pseudo-device (Firefox): the virtual browser default is
// selected until the user picks something.
b: { id: "", virtualEarpiece: false },
b: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
c: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
d: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
e: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
@@ -163,61 +158,6 @@ describe("AudioOutput Tests", () => {
});
});
it("falls back to the browser default when the selected device disappears", () => {
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
cold("a---b", {
a: DEVICE_LIST_B,
// The monitor is unplugged (or a Bluetooth sink changes profile)
b: [LAPTOP_SPEAKER],
}),
);
const audioOutput = new AudioOutput(
behavior("a", { a: true }),
testScope,
);
schedule("--a", {
a: () => audioOutput.select(MONITOR_SPEAKER.deviceId),
});
expectObservable(audioOutput.selected$).toBe("a-b-c", {
a: { id: "", virtualEarpiece: false },
b: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
// Rather than pinning some other physical device, let the browser route
c: { id: "", virtualEarpiece: false },
});
});
});
it("lists the virtual browser default first when there is no default pseudo-device", () => {
withTestScheduler(({ behavior, cold, expectObservable }) => {
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
cold("a", { a: DEVICE_LIST_B }),
);
const audioOutput = new AudioOutput(
behavior("a", { a: true }),
testScope,
);
expectObservable(audioOutput.available$).toBe("a", {
a: new Map([
["", { type: "default", name: null }],
[
LAPTOP_SPEAKER.deviceId,
{ type: "name", name: LAPTOP_SPEAKER.label },
],
[
MONITOR_SPEAKER.deviceId,
{ type: "name", name: MONITOR_SPEAKER.label },
],
]),
});
});
});
it("Test mappings", () => {
// In a real life setup there would be first a blanked list
// then the real one.
+26 -22
View File
@@ -16,6 +16,7 @@ import {
import { type Room as MatrixRoom } from "matrix-js-sdk";
import {
BehaviorSubject,
catchError,
combineLatest,
distinctUntilChanged,
filter,
@@ -38,7 +39,6 @@ import {
throttleTime,
timer,
takeUntil,
from,
} from "rxjs";
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
@@ -112,7 +112,7 @@ import {
TransportState,
} from "./localMember/LocalMember.ts";
import {
getLocalTransport,
createLocalTransport$,
type LocalTransport,
} from "./localMember/LocalTransport.ts";
import {
@@ -563,26 +563,20 @@ export function createCallViewModel$(
const ownMembershipIdentity: CallMembershipIdentityParts = {
userId,
deviceId,
// Consumed by the sticky membership manager as `member.id`, *and* stamped
// into every to-device key event by the key transport. A pre-sticky
// membership advertises `${userId}:${deviceId}` as its `membershipID`
// instead, so a uuid there names a member no peer can resolve.
memberId:
matrixRTCMode === MatrixRTCMode.Matrix_2_0
? uuidv4()
: `${userId}:${deviceId}`,
// This will only be consumed by the sticky membership manager. So it has no impact on legacy calls.
memberId: uuidv4(),
};
const localTransport$ = options.localTransport
? constant(options.localTransport)
: from(
getLocalTransport({
ownMembershipIdentity,
client,
roomId: matrixRoom.roomId,
matrixRTCMode,
}),
);
const localTransport =
options.localTransport ??
createLocalTransport$({
scope: scope,
memberships$: memberships$,
ownMembershipIdentity,
client,
roomId: matrixRoom.roomId,
matrixRTCMode,
});
const connectionFactory =
options.connectionFactory ??
@@ -599,7 +593,17 @@ export function createCallViewModel$(
const connectionManager = createConnectionManager$({
scope: scope,
connectionFactory: connectionFactory,
localTransport$,
localTransport$: scope.behavior(
localTransport.active$.pipe(
catchError((e: unknown) => {
logger.info(
"could not pass local transport to createConnectionManager$. localTransport$ threw an error",
e,
);
return of(null);
}),
),
),
remoteTransports$: membershipsAndTransports.transports$,
logger: logger,
ownMembershipIdentity,
@@ -655,7 +659,7 @@ export function createCallViewModel$(
connectionManager,
client,
matrixRTCSession,
localTransport$,
localTransport,
roomId: matrixRoom.roomId,
hideScreensharing,
hostBridge,
@@ -210,8 +210,11 @@ export function withCallViewModel(mode: MatrixRTCMode) {
connectionState$,
windowSize$,
localTransport: {
transport: exampleTransport,
sfuConfig: exampleSfuConfig,
active$: constant({
transport: exampleTransport,
sfuConfig: exampleSfuConfig,
}),
advertised$: constant(exampleTransport),
},
connectionFactory: {
createConnection(
@@ -21,7 +21,7 @@ import {
beforeEach,
afterEach,
} from "vitest";
import { BehaviorSubject, map, of, Subject } from "rxjs";
import { BehaviorSubject, map, of } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type LocalParticipant, type LocalTrack } from "livekit-client";
import fetchMock from "fetch-mock";
@@ -50,7 +50,10 @@ import {
TrackState,
watchScreenShareToggle,
} from "./LocalMember";
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import {
FailToGetOpenIdToken,
MatrixRTCTransportMissingError,
} from "../../../utils/errors";
import { Epoch, ObservableScope } from "../../ObservableScope";
import { constant } from "../../Behavior";
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
@@ -58,7 +61,10 @@ import { ConnectionState, type Connection } from "../remoteMembers/Connection";
import { type Publisher } from "./Publisher";
import { initializeWidget } from "../../../widget";
import { nullHostBridge } from "../../../HostBridge";
import { type LocalTransport } from "./LocalTransport";
import {
type LocalTransport,
type LocalTransportWithSFUConfig,
} from "./LocalTransport";
import * as openIDSFU from "../../../livekit/openIDSFU";
initializeWidget();
@@ -255,7 +261,7 @@ describe("LocalMembership", () => {
});
it("throws error on missing RTC config error", () => {
withTestScheduler(({ scope, hot, expectObservable }) => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
null,
@@ -271,15 +277,16 @@ describe("LocalMembership", () => {
),
};
const aLocalTransport: LocalTransport = {
advertised$: localTransport$,
active$: behavior("a", { a: null }),
};
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager,
localTransport$: hot(
"1ms #",
{},
new MatrixRTCTransportMissingError("domain.com"),
),
localTransport: aLocalTransport,
});
expectObservable(localMembership.localMemberState$).toBe("ne", {
@@ -289,9 +296,61 @@ describe("LocalMembership", () => {
});
});
it("Should not publish to active transport if advertised has errors", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
const advertised$ = scope.behavior<null | LivekitTransportConfig>(
hot("--#", {}, new FailToGetOpenIdToken(new Error("foo"))),
null,
);
// Populate a connection for active
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportBConnected, []);
const mockConnectionManager = {
transports$: constant(new Epoch([bTransport])),
connectionManagerData$: constant(new Epoch(connectionManagerData)),
};
const aLocalTransport: LocalTransport = {
advertised$,
active$: behavior("a", { n: null, a: bTransportWithSFUConfig }),
};
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
() => {
return {} as unknown as Publisher;
},
);
const publisherFactory =
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
typeof vi.fn
>;
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager,
localTransport: aLocalTransport,
});
expectObservable(localMembership.localMemberState$).toBe("n-e", {
n: TransportState.Waiting,
e: expect.toSatisfy((e) => e instanceof FailToGetOpenIdToken),
});
// Should not have created any publisher
expect(publisherFactory).toHaveBeenCalledTimes(0);
});
});
it("logs if callIntent cannot be updated", async () => {
const scope = new ObservableScope();
const aLocalTransport: LocalTransport = {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
};
const mockConnectionManager = {
transports$: constant(new Epoch([])),
connectionManagerData$: constant(new Epoch(new ConnectionManagerData())),
@@ -307,7 +366,7 @@ describe("LocalMembership", () => {
leaveRoomSession: vi.fn(),
},
connectionManager: mockConnectionManager,
localTransport$: constant(mockTransport),
localTransport: aLocalTransport,
});
const expextedLog =
"'not connected yet' while updating the call intent (this is expected on startup)";
@@ -319,19 +378,33 @@ describe("LocalMembership", () => {
scope.end();
});
const mockTransportConfig = {
const aTransport = {
livekit_service_url: "a",
} as LivekitTransportConfig;
const mockTransport = {
transport: mockTransportConfig,
const aTransportWithSFUConfig = {
transport: aTransport,
sfuConfig: {
jwt: "foo",
livekitAlias: "bar",
livekitIdentity: "baz",
url: "bro",
},
} as LocalTransport;
} as LocalTransportWithSFUConfig;
const bTransport = {
livekit_service_url: "b",
} as LivekitTransportConfig;
const bTransportWithSFUConfig = {
transport: bTransport,
sfuConfig: {
jwt: "foo2",
livekitAlias: "bar2",
livekitIdentity: "baz2",
url: "bro2",
},
} as LocalTransportWithSFUConfig;
const connectionTransportAConnected = {
livekitRoom: mockLivekitRoom({
@@ -341,13 +414,18 @@ describe("LocalMembership", () => {
} as unknown as LocalParticipant,
}),
state$: constant(ConnectionState.LivekitConnected),
transport: mockTransportConfig,
} as Connection;
transport: aTransport,
} as unknown as Connection;
const connectionTransportAConnecting = {
...connectionTransportAConnected,
state$: constant(ConnectionState.LivekitConnecting),
livekitRoom: mockLivekitRoom({}),
} as unknown as Connection;
const connectionTransportBConnected = {
state$: constant(ConnectionState.LivekitConnected),
transport: bTransport,
livekitRoom: mockLivekitRoom({}),
} as unknown as Connection;
const authCallSpy = vi
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
@@ -381,7 +459,10 @@ describe("LocalMembership", () => {
),
},
joinMatrixRTC,
localTransport$: constant(mockTransport),
localTransport: {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
delayId$,
});
@@ -390,7 +471,7 @@ describe("LocalMembership", () => {
await flushPromises();
// Joins with timings appropriate for the level of delegation support
expect(joinMatrixRTC).toHaveBeenCalledWith(
mockTransportConfig,
aTransport,
delayedLeaveTimings,
);
@@ -424,7 +505,75 @@ describe("LocalMembership", () => {
},
);
it("only starts tracks if requested", async () => {
it("recreates publisher if new connection is used, always unpublish and end tracks", async () => {
const scope = new ObservableScope();
const activeTransport$ = new BehaviorSubject(aTransportWithSFUConfig);
const aLocalTransport: LocalTransport = {
advertised$: constant(aTransport),
active$: activeTransport$,
};
const publishers: Publisher[] = [];
let seed = 0;
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
() => {
const a = seed;
seed += 1;
logger.info(`creating [${a}]`);
const p = {
// It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does
// all the cleanup we need.
destroy: vi.fn(),
stopPublishing: vi.fn().mockImplementation(() => {
logger.info(`stopPublishing [${a}]`);
}),
stopTracks: vi.fn(),
};
publishers.push(p as unknown as Publisher);
return p;
},
);
const publisherFactory =
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
typeof vi.fn
>;
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
connectionManagerData.add(connectionTransportBConnected, []);
createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport: aLocalTransport,
});
await flushPromises();
activeTransport$.next({
...aTransportWithSFUConfig,
transport: bTransport,
});
await flushPromises();
expect(publisherFactory).toHaveBeenCalledTimes(2);
expect(publishers.length).toBe(2);
// stop the first Publisher and let the second one life.
expect(publishers[0].destroy).toHaveBeenCalled();
expect(publishers[1].destroy).not.toHaveBeenCalled();
expect(publisherFactory.mock.calls[0][0].transport).toBe(aTransport);
expect(publisherFactory.mock.calls[1][0].transport).toBe(bTransport);
scope.end();
await flushPromises();
// stop all tracks after ending scopes
expect(publishers[1].destroy).toHaveBeenCalled();
// expect(publishers[1].stopTracks).toHaveBeenCalled();
defaultCreateLocalMemberValues.createPublisherFactory.mockReset();
});
it("only start tracks if requested", async () => {
const scope = new ObservableScope();
const publishers: Publisher[] = [];
@@ -453,6 +602,11 @@ describe("LocalMembership", () => {
typeof vi.fn
>;
const aLocalTransport: LocalTransport = {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
};
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
// connectionManagerData.add(connectionTransportB, []);
@@ -462,7 +616,7 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
localTransport: aLocalTransport,
});
await flushPromises();
expect(publisherFactory).toHaveBeenCalledOnce();
@@ -483,8 +637,16 @@ describe("LocalMembership", () => {
//
it("tracks livekit state correctly", async () => {
const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData();
const localTransport$ = new Subject<LocalTransport>();
const activeTransport$ =
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
const aLocalTransport: LocalTransport = {
advertised$: constant(aTransport),
active$: activeTransport$,
};
const connectionManagerData$ = new BehaviorSubject(
new Epoch(connectionManagerData),
@@ -525,14 +687,14 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$,
},
localTransport$,
localTransport: aLocalTransport,
});
await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual(
TransportState.Waiting,
);
localTransport$.next(mockTransport);
activeTransport$.next(aTransportWithSFUConfig);
await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected,
@@ -557,7 +719,7 @@ describe("LocalMembership", () => {
});
(
connectionManagerData2.getConnectionForTransport(mockTransportConfig)!
connectionManagerData2.getConnectionForTransport(aTransport)!
.state$ as BehaviorSubject<ConnectionState>
).next(ConnectionState.LivekitConnected);
expect(localMembership.localMemberState$.value).toStrictEqual({
@@ -660,7 +822,10 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
localTransport: {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
});
await flushPromises();
@@ -697,7 +862,10 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
localTransport: {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
});
await flushPromises();
@@ -745,7 +913,10 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
localTransport: {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
});
await flushPromises();
@@ -786,7 +957,10 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
localTransport: {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
});
await flushPromises();
@@ -852,7 +1026,10 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
localTransport: {
advertised$: constant(aTransport),
active$: constant(aTransportWithSFUConfig),
},
});
return { scope, localMembership };
};
@@ -862,7 +1039,7 @@ describe("LocalMembership", () => {
const setScreenShareEnabled = vi.fn().mockRejectedValue(error);
const connection = {
state$: constant(ConnectionState.LivekitConnected),
transport: mockTransportConfig,
transport: aTransport,
livekitRoom: mockLivekitRoom({
localParticipant: mockLocalParticipant({
isScreenShareEnabled: false,
@@ -38,10 +38,6 @@ import {
startWith,
switchMap,
tap,
NEVER,
concat,
race,
Subject,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils";
@@ -81,6 +77,8 @@ import {
} from "../remoteMembers/Connection.ts";
import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { type LocalTransport } from "./LocalTransport.ts";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
import { or$ } from "../../../utils/observable.ts";
import { getSFUConfigWithOpenID } from "../../../livekit/openIDSFU.ts";
export enum TransportState {
@@ -150,7 +148,7 @@ interface Props {
homeserverConnected: HomeserverConnected;
roomId: string;
ownMembershipIdentity: CallMembershipIdentityParts;
localTransport$: Observable<LocalTransport>;
localTransport: LocalTransport;
client: Pick<MatrixClient, "getDeviceId" | "getOpenIdToken">;
matrixRTCSession: Pick<
MatrixRTCSession,
@@ -196,7 +194,7 @@ interface Props {
export const createLocalMembership$ = ({
scope,
connectionManager,
localTransport$: localTransportWithErrors$,
localTransport,
homeserverConnected,
createPublisherFactory,
joinMatrixRTC,
@@ -254,24 +252,20 @@ export const createLocalMembership$ = ({
const logger = parentLogger.getChild("[LocalMembership]");
logger.debug(`Creating local membership..`);
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
const fatalTransportError$ = new Subject<ElementCallError>();
const localTransport$ = localTransportWithErrors$.pipe(
catchError((e: unknown) => {
let error: ElementCallError;
if (e instanceof ElementCallError) {
error = e;
} else {
error = new UnknownCallError(
e instanceof Error
? e
: new Error("Unknown error from localTransport"),
);
}
fatalTransportError$.next(error);
return NEVER; // Make this Observable swallow the error
}),
);
// We consider error on the transport as fatal.
// Whether it is the active transport or the preferred transport.
const handleTransportError = (e: unknown): Observable<null> => {
let error: ElementCallError;
if (e instanceof ElementCallError) {
error = e;
} else {
error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error from localTransport"),
);
}
setTransportError(error);
return of(null);
};
async function checkDelegationSupport(
endpointUrl: string,
@@ -309,40 +303,58 @@ export const createLocalMembership$ = ({
// The transport that we will advertise in our membership, paired with info as
// to whether delayed event delegation is supported
const joinParams$ = scope.behavior(
localTransport$.pipe(
switchMap(async ({ transport }) => {
const transportSupportsDelegation = checkDelegationSupport(
transport.livekit_service_url + "/delegate_delayed_leave",
`transport ${transport.livekit_service_url}`,
);
return {
transport,
delegationSupported:
(await homeserverSupportsDelegation) ||
(await transportSupportsDelegation),
};
const joinParams$ = localTransport.advertised$.pipe(
catchError(handleTransportError),
distinctUntilChanged(areLivekitTransportsEqual),
switchMap((transport) => {
if (transport === null) return of(null);
const transportSupportsDelegation = checkDelegationSupport(
transport.livekit_service_url + "/delegate_delayed_leave",
`transport ${transport.livekit_service_url}`,
);
return or$(
from(homeserverSupportsDelegation),
from(transportSupportsDelegation),
).pipe(
map((delegationSupported) => ({ transport, delegationSupported })),
startWith(null),
);
}),
);
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
const activeTransport$ = scope.behavior(
combineLatest([localTransport.active$, localTransport.advertised$]).pipe(
map(([active, advertised]) => {
// Our policy is to not publish to another transport if our prefered transport is miss-configured
if (advertised == null) return null;
return active?.transport ?? null;
}),
catchError(handleTransportError),
distinctUntilChanged(areLivekitTransportsEqual),
),
null,
);
// Drop Epoch data here since we will not combine this anymore
const localConnection$ = scope.behavior(
combineLatest([
connectionManager.connectionManagerData$,
localTransport$,
activeTransport$,
]).pipe(
map(([{ value: connectionData }, { transport }]) =>
connectionData.getConnectionForTransport(transport),
),
map(([{ value: connectionData }, localTransport]) => {
if (localTransport === null) {
return null;
}
return connectionData.getConnectionForTransport(localTransport);
}),
tap((connection) => {
logger.info(
`Local connection updated: ${connection?.transport?.livekit_service_url}`,
);
}),
),
null,
);
// Tracks error that happen when creating the local tracks.
@@ -478,6 +490,18 @@ export const createLocalMembership$ = ({
}
};
const fatalTransportError$ = new BehaviorSubject<ElementCallError | null>(
null,
);
const setTransportError = (e: ElementCallError): void => {
if (fatalTransportError$.value !== null) {
logger.error("Multiple Transport Errors:", e);
} else {
fatalTransportError$.next(e);
}
};
const localConnectionState$ = localConnection$.pipe(
switchMap((connection) => (connection ? connection.state$ : of(null))),
);
@@ -485,29 +509,38 @@ export const createLocalMembership$ = ({
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
combineLatest([
localConnectionState$,
activeTransport$,
joinAndPublishRequested$,
from(trackStartRequested.promise).pipe(
map(() => true),
startWith(false),
),
]).pipe(
map(([localConnectionState, shouldPublish, shouldStartTracks]) => {
const trackState: TrackState = shouldStartTracks
? TrackState.Ready
: TrackState.WaitingForUser;
map(
([
localConnectionState,
localTransport,
shouldPublish,
shouldStartTracks,
]) => {
if (!localTransport) return null;
const trackState: TrackState = shouldStartTracks
? TrackState.Ready
: TrackState.WaitingForUser;
if (
localConnectionState !== ConnectionState.LivekitConnected ||
trackState !== TrackState.Ready
)
return {
connection: localConnectionState,
tracks: trackState,
};
if (!shouldPublish) return PublishState.WaitingForUser;
// if (!publishing) return PublishState.Starting;
return PublishState.Publishing;
}),
if (
localConnectionState !== ConnectionState.LivekitConnected ||
trackState !== TrackState.Ready
)
return {
connection: localConnectionState,
tracks: trackState,
};
if (!shouldPublish) return PublishState.WaitingForUser;
// if (!publishing) return PublishState.Starting;
return PublishState.Publishing;
},
),
distinctUntilChanged(deepCompare),
),
);
@@ -521,36 +554,30 @@ export const createLocalMembership$ = ({
};
const localMemberState$ = scope.behavior<LocalMemberState>(
concat(
// Waiting until…
of(TransportState.Waiting),
race(
// either there is a fatal transport error
fatalTransportError$,
// or the transport is available.
localTransport$.pipe(
switchMap(() =>
// Once available, track session/media state.
combineLatest([
mediaState$,
homeserverConnected.rtsSession$,
fatalMatrixError$,
publishError$,
]).pipe(
map(
([
mediaState,
rtcSessionStatus,
fatalMatrixError,
publishError,
]) => ({
matrix: fatalMatrixError ?? rtcSessionStatus,
media: publishError ?? mediaState,
}),
),
),
),
),
combineLatest([
mediaState$,
homeserverConnected.rtsSession$,
fatalMatrixError$,
fatalTransportError$,
publishError$,
]).pipe(
map(
([
mediaState,
rtcSessionStatus,
fatalMatrixError,
fatalTransportError,
publishError,
]) => {
if (fatalTransportError !== null) return fatalTransportError;
// `mediaState` will be 'null' until the transport/connection appears.
if (mediaState && rtcSessionStatus)
return {
matrix: fatalMatrixError ?? rtcSessionStatus,
media: publishError ?? mediaState,
};
return TransportState.Waiting;
},
),
),
);
@@ -658,7 +685,7 @@ export const createLocalMembership$ = ({
});
});
// Join and leave the session as needed
// Keep matrix rtc session in sync with advertisedTransport$, connectRequested$
scope.reconcile(
scope.behavior(combineLatest([joinParams$, joinAndPublishRequested$])),
async ([joinParams, shouldConnect]) => {
@@ -14,10 +14,19 @@ import {
type MockedObject,
vi,
} from "vitest";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { lastValueFrom } from "rxjs";
import fetchMock from "fetch-mock";
import { mockConfig, ownMemberMock } from "../../../utils/test";
import { getLocalTransport } from "./LocalTransport";
import {
mockConfig,
flushPromises,
ownMemberMock,
testScope,
} from "../../../utils/test";
import { createLocalTransport$ } from "./LocalTransport";
import { constant } from "../../Behavior";
import { Epoch, ObservableScope } from "../../ObservableScope";
import {
MatrixRTCTransportMissingError,
FailToGetOpenIdToken,
@@ -38,79 +47,118 @@ describe("LocalTransport", () => {
beforeEach(() => vi.clearAllMocks());
it("throws if config is missing", async () => {
await expect(
getLocalTransport({
roomId: "!room:example.org",
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}),
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
const { advertised$, active$ } = createLocalTransport$({
scope: testScope(),
roomId: "!room:example.org",
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
});
await flushPromises();
expect(() => advertised$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
);
expect(() => active$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
);
});
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
// Provide a valid config so makeTransportInternal resolves a transport
const scope = new ObservableScope();
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
new FailToGetOpenIdToken(new Error("no openid")),
const resolver = Promise.withResolvers<void>();
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockImplementation(
async () => {
await resolver.promise;
throw new FailToGetOpenIdToken(new Error("no openid"));
},
);
const observations: unknown[] = [];
const errors: Error[] = [];
const { advertised$, active$ } = createLocalTransport$({
scope,
roomId: "!example_room_id",
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
});
active$.subscribe(
(o) => observations.push(o),
(e) => errors.push(e),
);
resolver.resolve();
await flushPromises();
await expect(
getLocalTransport({
roomId: "!example_room_id",
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}),
).rejects.toThrow(new FailToGetOpenIdToken(new Error("no openid")));
const expectedError = new FailToGetOpenIdToken(new Error("no openid"));
expect(observations).toStrictEqual([null]);
expect(errors).toStrictEqual([expectedError]);
expect(() => advertised$.value).toThrow(expectedError);
expect(() => active$.value).toThrow(expectedError);
});
it("returns preferred transport", async () => {
it("emits preferred transport after OpenID resolves", async () => {
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue({
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
openIdResolver.promise,
);
const { advertised$, active$ } = createLocalTransport$({
scope: testScope(),
roomId: "!room:example.org",
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
});
openIdResolver.resolve?.({
url: "https://lk.example.org",
jwt: "jwt",
livekitAlias: "Akph4alDMhen",
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
});
expect(
await getLocalTransport({
roomId: "!room:example.org",
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}),
).toStrictEqual({
transport: {
livekit_service_url: "https://lk.example.org",
type: "livekit",
},
expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null);
await flushPromises();
// final
const expectedTransport = {
livekit_service_url: "https://lk.example.org",
type: "livekit",
};
expect(advertised$.value).toStrictEqual(expectedTransport);
expect(active$.value).toStrictEqual({
transport: expectedTransport,
sfuConfig: {
jwt: "jwt",
livekitAlias: "Akph4alDMhen",
@@ -120,19 +168,22 @@ describe("LocalTransport", () => {
});
});
type LocalTransportProps = Parameters<typeof getLocalTransport>[0];
type LocalTransportProps = Parameters<typeof createLocalTransport$>[0];
describe("transport configuration mechanisms", () => {
let localTransportOpts: LocalTransportProps & {
client: MockedObject<LocalTransportProps["client"]>;
};
let openIdResolver: PromiseWithResolvers<openIDSFU.SFUConfig>;
beforeEach(() => {
mockConfig({});
customLivekitUrl.setValue(customLivekitUrl.defaultValue);
localTransportOpts = {
ownMembershipIdentity: ownMemberMock,
scope: testScope(),
roomId: "!example_room_id",
matrixRTCMode: MatrixRTCMode.Compatibility,
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -141,6 +192,10 @@ describe("LocalTransport", () => {
getDeviceId: vi.fn(),
},
};
openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
openIdResolver.promise,
);
});
afterEach(() => {
@@ -151,15 +206,19 @@ describe("LocalTransport", () => {
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
openIdResponse,
);
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
transport: {
livekit_service_url: "https://lk.example.org",
type: "livekit",
},
const { advertised$, active$ } =
createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null);
await flushPromises();
const expectedTransport = {
livekit_service_url: "https://lk.example.org",
type: "livekit",
};
expect(advertised$.value).toStrictEqual(expectedTransport);
expect(active$.value).toStrictEqual({
transport: expectedTransport,
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
@@ -171,11 +230,12 @@ describe("LocalTransport", () => {
it("supports getting transport via user settings", async () => {
customLivekitUrl.setValue("https://lk.example.org");
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
openIdResponse,
);
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
const { advertised$, active$ } =
createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(advertised$.value).toBe(null);
await flushPromises();
expect(active$.value).toStrictEqual({
transport: {
livekit_service_url: "https://lk.example.org",
type: "livekit",
@@ -193,15 +253,19 @@ describe("LocalTransport", () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
]);
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
openIdResponse,
);
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
transport: {
livekit_service_url: "https://lk.example.org",
type: "livekit",
},
const { advertised$, active$ } =
createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null);
await flushPromises();
const expectedTransport = {
livekit_service_url: "https://lk.example.org",
type: "livekit",
};
expect(advertised$.value).toStrictEqual(expectedTransport);
expect(active$.value).toStrictEqual({
transport: expectedTransport,
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
@@ -215,31 +279,38 @@ describe("LocalTransport", () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
]);
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
openIdResolver.reject(
new FailToGetOpenIdToken(new Error("Test driven error")),
);
await expect(getLocalTransport(localTransportOpts)).rejects.toThrow(
expect.any(FailToGetOpenIdToken),
);
await expect(async () =>
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
});
it("throws if no options are available", async () => {
await expect(
getLocalTransport({
ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id",
matrixRTCMode: MatrixRTCMode.Compatibility,
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
}),
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
const { advertised$, active$ } = createLocalTransport$({
scope: testScope(),
ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id",
matrixRTCMode: MatrixRTCMode.Compatibility,
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
});
await flushPromises();
expect(() => advertised$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
);
expect(() => active$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
);
});
});
});
@@ -5,11 +5,17 @@ SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
import {
type CallMembership,
type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk";
import { distinctUntilChanged, from, map, of, switchMap } from "rxjs";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior.ts";
import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
import { Config } from "../../../config/Config.ts";
import {
FailToGetOpenIdToken,
@@ -21,12 +27,19 @@ import {
type SFUConfig,
type OpenIDClientParts,
} from "../../../livekit/openIDSFU.ts";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
import { customLivekitUrl } from "../../../settings/settings.ts";
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
import { type MatrixRTCMode } from "../../../config/ConfigOptions.ts";
/*
* It figures out “which LiveKit focus URL/alias the local user should use,”
* and ensures the SFU path is primed before advertising that choice.
*/
interface Props {
scope: ObservableScope;
ownMembershipIdentity: CallMembershipIdentityParts;
memberships$: Behavior<Epoch<CallMembership[]>>;
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
OpenIDClientParts;
// Used by the jwt service to create the livekit room and compute the livekit alias.
@@ -55,65 +68,114 @@ interface Props {
//
// 2.
// We need to make sure we do not sent livekit_alias in sticky events and that we drop all code for sending state events!
export interface LocalTransport {
export interface LocalTransportWithSFUConfig {
transport: LivekitTransportConfig;
sfuConfig: SFUConfig;
}
export function isLocalTransport(
obj: LivekitTransportConfig | LocalTransport,
): obj is LocalTransport {
export function isLocalTransportWithSFUConfig(
obj: LivekitTransportConfig | LocalTransportWithSFUConfig,
): obj is LocalTransportWithSFUConfig {
return "transport" in obj && "sfuConfig" in obj;
}
export interface LocalTransport {
/**
* The transport to be advertised in our MatrixRTC membership. `null` when not
* yet fetched/validated.
*/
advertised$: Behavior<LivekitTransportConfig | null>;
/**
* The transport to connect to and publish media on. `null` when not yet known
* or available.
*/
active$: Behavior<LocalTransportWithSFUConfig | null>;
}
/**
* Connects to the JWT service and determines the transport that the local member should use.
* Connects to the JWT service and determines the transports that the local member should use.
*
* @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
* (which is expected for non sticky event based rtc member events)
* @returns The transport to advertise in our MatrixRTC membership and publish media on.
* @returns The transport to advertise in the local MatrixRTC membership, along with the transport to actively publish media to.
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
*/
export async function getLocalTransport({
export const createLocalTransport$ = ({
scope,
memberships$,
ownMembershipIdentity,
client,
roomId,
matrixRTCMode,
}: Props): Promise<LocalTransport> {
}: Props): LocalTransport => {
const logger = rootLogger.getChild("[LocalTransport]");
const discovery = new RtcTransportAutoDiscovery({
const transportDiscovery = new RtcTransportAutoDiscovery({
client: client,
resolvedConfig: Config.get(),
logger: logger,
});
const customUrl = customLivekitUrl.value$.value;
// Respect the user's custom URL, if set
const transport: LivekitTransportConfig | null = customUrl
? { type: "livekit", livekit_service_url: customUrl }
: await discovery.discoverPreferredTransport();
// Get the preferred transport from the current deployment.
const discoveredTransport$ = from(
transportDiscovery.discoverPreferredTransport(),
);
if (transport === null)
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
const preferredConfig$ = customLivekitUrl.value$.pipe(
switchMap((customUrl) => {
if (customUrl) {
return of({
type: "livekit",
livekit_service_url: customUrl,
} as LivekitTransportConfig);
} else {
return discoveredTransport$;
}
}),
map((config) => {
if (!config) {
// Bubbled up from the preferredConfig$ observable.
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
}
return config;
}),
distinctUntilChanged(areLivekitTransportsEqual),
);
try {
return await doOpenIdAndJWTFromUrl(
transport,
matrixRTCMode,
ownMembershipIdentity,
roomId,
client,
logger,
);
} catch (e) {
logger.error(
`Failed to authenticate to transport ${transport.livekit_service_url}`,
e,
);
throw mapAuthErrorToUserFriendlyError(e);
}
}
const preferredTransport$ = preferredConfig$.pipe(
switchMap(async (transport) => {
try {
return await doOpenIdAndJWTFromUrl(
transport,
matrixRTCMode,
ownMembershipIdentity,
roomId,
client,
logger,
);
} catch (e) {
logger.error(
`Failed to authenticate to transport ${transport.livekit_service_url}`,
e,
);
throw mapAuthErrorToUserFriendlyError(e);
}
}),
);
// Always publish on and advertise the preferred transport.
return {
advertised$: scope.behavior(
preferredTransport$.pipe(
map((t) => t.transport),
distinctUntilChanged(areLivekitTransportsEqual),
),
null,
),
active$: scope.behavior(preferredTransport$, null),
};
};
/**
* Utility to ensure the user can authenticate with the SFU.
@@ -137,7 +199,7 @@ async function doOpenIdAndJWTFromUrl(
roomId: string,
client: Pick<MatrixClient, "_unstable_getRTCTransports"> & OpenIDClientParts,
logger?: Logger,
): Promise<LocalTransport> {
): Promise<LocalTransportWithSFUConfig> {
const sfuConfig = await getSFUConfigWithOpenID(
client,
membership,
@@ -394,34 +394,6 @@ describe("Start connection states", () => {
expect(connectedState).toEqual(ConnectionState.LivekitConnected);
});
it("stopping while connecting does not report an error", async () => {
setupTest();
const connection = setupRemoteConnection();
const capturedStates: (ConnectionState | Error)[] = [];
const s = connection.state$.subscribe((value) => {
capturedStates.push(value);
});
onTestFinished(() => s.unsubscribe());
// livekit-client rejects a pending connect() when disconnect() is called.
const pendingConnect = Promise.withResolvers<void>();
fakeLivekitRoom.connect.mockReturnValue(pendingConnect.promise);
fakeLivekitRoom.disconnect.mockResolvedValue(undefined);
const started = connection.start();
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
const stopping = connection.stop();
pendingConnect.reject(new Error("Client initiated disconnect"));
await stopping;
// start() resolves rather than rejecting (it is not awaited by the
// ConnectionManager, so a rejection would be unhandled).
await expect(started).resolves.toBeUndefined();
expect(capturedStates.at(-1)).toEqual(ConnectionState.Stopped);
expect(capturedStates.some((st) => st instanceof Error)).toBe(false);
});
it("shutting down the scope should stop the connection", async () => {
setupTest();
vi.useFakeTimers();
@@ -373,15 +373,6 @@ export class Connection {
// If we were stopped while connecting, don't proceed to update state.
if (this.stopped) return;
} catch (error) {
if (this.stopped) {
// stop() was called while we were connecting, which makes the pending
// connect reject. That is the abort we asked for, not a failure, so
// don't record an error state on a stopped connection or rethrow it
// (start() is not awaited by the ConnectionManager, so a throw here
// becomes an unhandled promise rejection).
this.logger.debug(`Connect aborted because the connection was stopped`);
return;
}
this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
this._state$.next(
error instanceof ElementCallError
@@ -420,11 +411,9 @@ export class Connection {
`stop: disconnecing from lk room ${this.transport.livekit_service_url}`,
);
if (this.stopped) return;
// Mark as stopped before disconnecting so that a connect() aborted by the
// disconnect sees the flag and does not report the abort as an error.
this.stopped = true;
await this.livekitRoom.disconnect();
this._state$.next(ConnectionState.Stopped);
this.stopped = true;
this.logger.debug(
`stop: DONE disconnecing from lk room ${this.transport.livekit_service_url}`,
);
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BehaviorSubject, NEVER } from "rxjs";
import { BehaviorSubject } from "rxjs";
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
import { type RemoteParticipant } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
@@ -20,7 +20,7 @@ import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { type Connection } from "./Connection.ts";
import { ownMemberMock, withTestScheduler } from "../../../utils/test.ts";
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { type Behavior } from "../../Behavior.ts";
import { constant, type Behavior } from "../../Behavior.ts";
// Some test constants
@@ -74,7 +74,7 @@ describe("connections$ stream", () => {
const { connectionManagerData$ } = createConnectionManager$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport$: NEVER,
localTransport$: constant(null),
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}),
@@ -115,7 +115,7 @@ describe("connections$ stream", () => {
const { connectionManagerData$ } = createConnectionManager$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport$: NEVER,
localTransport$: constant(null),
remoteTransports$: behavior("abcdef", {
a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1], 1),
@@ -162,7 +162,7 @@ describe("connections$ stream", () => {
const { connectionManagerData$ } = createConnectionManager$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport$: NEVER,
localTransport$: constant(null),
remoteTransports$: behavior("abc", {
a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1),
@@ -297,7 +297,7 @@ describe("connectionManagerData$ stream", () => {
const { connectionManagerData$ } = createConnectionManager$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport$: NEVER,
localTransport$: constant(null),
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}),
@@ -7,16 +7,7 @@ Please see LICENSE in the repository root for full details.
*/
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
import {
combineLatest,
map,
type Observable,
of,
switchMap,
startWith,
catchError,
NEVER,
} from "rxjs";
import { combineLatest, map, of, switchMap } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type RemoteParticipant } from "livekit-client";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
@@ -28,8 +19,8 @@ import { generateItemsWithEpoch } from "../../../utils/observable.ts";
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts";
import {
isLocalTransport,
type LocalTransport,
isLocalTransportWithSFUConfig,
type LocalTransportWithSFUConfig,
} from "../localMember/LocalTransport.ts";
import { type SFUConfig } from "../../../livekit/openIDSFU.ts";
@@ -87,7 +78,7 @@ export class ConnectionManagerData {
interface Props {
scope: ObservableScope;
connectionFactory: ConnectionFactory;
localTransport$: Observable<LocalTransport>;
localTransport$: Behavior<LocalTransportWithSFUConfig | null>;
remoteTransports$: Behavior<Epoch<LivekitTransportConfig[]>>;
logger: Logger;
@@ -130,14 +121,6 @@ export function createConnectionManager$({
const logger = parentLogger.getChild("[ConnectionManager]");
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
const localTransportAsArray$ = localTransport$.pipe(
// LocalMember already surfaces local transport errors properly in the UI,
// here we can just swallow them
catchError(() => NEVER),
map((transport) => [transport]),
startWith([]),
);
/**
* All transports currently managed by the ConnectionManager.
*
@@ -147,12 +130,16 @@ export function createConnectionManager$({
* externally this is modified via `registerTransports()`.
*/
const localAndRemoteTransports$: Behavior<
Epoch<(LivekitTransportConfig | LocalTransport)[]>
Epoch<(LivekitTransportConfig | LocalTransportWithSFUConfig)[]>
> = scope.behavior(
combineLatest([localTransportAsArray$, remoteTransports$]).pipe(
combineLatest([remoteTransports$, localTransport$]).pipe(
// Combine local and remote transports into one transport array
// and set the forceOldJwtEndpoint property on the local transport
map(([localTransportAsArray, remoteTransports]) => {
map(([remoteTransports, localTransport]) => {
let localTransportAsArray: LocalTransportWithSFUConfig[] = [];
if (localTransport) {
localTransportAsArray = [localTransport];
}
const dedupedRemote = removeDuplicateTransports(remoteTransports.value);
const remoteWithoutLocal = dedupedRemote.filter(
(transport) =>
@@ -183,9 +170,8 @@ export function createConnectionManager$({
"ConnectionManager connections$",
function* (transports) {
for (const transport of transports) {
if (isLocalTransport(transport)) {
// This is the local transport; only the `LocalTransport`
// interface has a `sfuConfig` field.
if (isLocalTransportWithSFUConfig(transport)) {
// This is the local transport; only the `LocalTransportWithSFUConfig` has a `sfuConfig` field.
yield {
keys: [
transport.transport.livekit_service_url,
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { test, vi, expect, beforeEach, afterEach } from "vitest";
import { BehaviorSubject, NEVER } from "rxjs";
import { BehaviorSubject } from "rxjs";
import { type Room as LivekitRoom } from "livekit-client";
import EventEmitter from "events";
import fetchMock from "fetch-mock";
@@ -34,6 +34,7 @@ import {
} from "./MatrixLivekitMembers.ts";
import { createConnectionManager$ } from "./ConnectionManager.ts";
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
import { constant } from "../../Behavior.ts";
import { localRtcMember, testJWTToken } from "../../../utils/test-fixtures.ts";
// Test the integration of ConnectionManager and MatrixLivekitMerger
@@ -123,7 +124,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
const connectionManager = createConnectionManager$({
scope: testScope,
connectionFactory: ecConnectionFactory,
localTransport$: NEVER,
localTransport$: constant(null),
remoteTransports$: membershipsAndTransports.transports$,
logger: logger,
ownMembershipIdentity: ownMemberMock,
+7 -14
View File
@@ -257,21 +257,14 @@ export class AudioOutput implements MediaDevice<
map((availableRaw) => {
let available: Map<string, AudioOutputDeviceLabel> =
buildDeviceMap(availableRaw);
// Create a virtual default audio output for browsers that don't have one
// (Firefox, Safari). Its device ID must be the empty string because
// that's what setSinkId recognizes. It goes first so that it is the
// fallback when no output has been explicitly chosen (or the chosen
// one disappears), rather than pinning the first physical device
// with setSinkId: pinned sinks are not re-routed by the browser, and
// Firefox leaves the audio elements silent when a pinned sink goes
// away (e.g. a Bluetooth headset switching profile when its
// microphone is opened). We can't know which physical device the
// browser default resolves to, so the entry carries no name.
// Create a virtual default audio output for browsers that don't have one.
// Its device ID must be the empty string because that's what setSinkId
// recognizes.
if (available.size && !available.has("") && !available.has("default"))
available = new Map<string, AudioOutputDeviceLabel>([
["", { type: "default", name: null }],
...available,
]);
available.set("", {
type: "default",
name: availableRaw[0]?.label || null,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isSafari = !!(window as any).GestureEvent; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility
if (isSafari) {
+8 -4
View File
@@ -22,10 +22,6 @@ import {
} from "./ObservableScope";
import { type Behavior } from "./Behavior";
/**
* Tracks the transports used by ourselves, plus all other MatrixRTC session
* members.
*/
export const membershipsAndTransports$ = (
scope: ObservableScope,
memberships$: Behavior<Epoch<CallMembership[]>>,
@@ -35,6 +31,14 @@ export const membershipsAndTransports$ = (
>;
transports$: Behavior<Epoch<LivekitTransportConfig[]>>;
} => {
/**
* Lists the transports used by ourselves, plus all other MatrixRTC session
* members.
* For completeness this also lists the preferred transport and
* whether we are in multi-SFU mode or sticky events mode.
* `advertisedTransport$` reads these values together, so bundling them avoids inconsistent state or
* excessive updates when using RxJS.
*/
const membershipsWithTransport$: Behavior<
Epoch<
{
+22 -1
View File
@@ -9,9 +9,30 @@ import { expect, test } from "vitest";
import { type Observable, of, Subject, switchMap } from "rxjs";
import { withTestScheduler } from "./test";
import { filterBehavior, generateItems, pauseWhen } from "./observable";
import { or$, filterBehavior, generateItems, pauseWhen } from "./observable";
import { type Behavior } from "../state/Behavior";
const yesNo = {
y: true,
n: false,
};
test("or$", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const input1Marbles = "ny--n--";
const input2Marbles = "n-y--n-";
const input3Marbles = "n--y--n";
const outputMarbles = "nyyyyyn";
expectObservable(
or$(
behavior(input1Marbles, yesNo),
behavior(input2Marbles, yesNo),
behavior(input3Marbles, yesNo),
),
).toBe(outputMarbles, yesNo);
});
});
test("pauseWhen", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const inputMarbles = " abcdefgh-i-jk-";
+9
View File
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import {
type Observable,
audit,
combineLatest,
concat,
defer,
filter,
@@ -112,6 +113,14 @@ export function getValue<T>(state$: Observable<T>): T {
return value;
}
/**
* Creates an Observable that has a value of true whenever some of its inputs
* are true.
*/
export function or$(...inputs: Observable<boolean>[]): Observable<boolean> {
return combineLatest(inputs, (...flags) => flags.some((flag) => flag));
}
/**
* RxJS operator that pauses all changes in the input value whenever a Behavior
* is true. When the Behavior returns to being false, the most recently
+1 -1
View File
@@ -9,7 +9,7 @@ import { defineConfig, searchForWorkspaceRoot } from "vite";
import { realpathSync } from "node:fs";
import * as fs from "node:fs";
import { vitePluginsConfig } from "./vite.config.ts";
import { vitePluginsConfig } from "./vite.config";
import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot";
// Serves the harness under `component/dev`, which embeds Element Call as a
+1 -1
View File
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { defineConfig } from "vite";
import { vitePluginsConfig } from "./vite.config.ts";
import { vitePluginsConfig } from "./vite.config";
import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot";
// Config for Element Call as a React component, to be imported by an
+1 -1
View File
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { defineConfig, mergeConfig } from "vite";
import generateFile from "vite-plugin-generate-file";
import fullConfig from "./vite.config.ts";
import fullConfig from "./vite.config";
const base = "./";
+1 -1
View File
@@ -1,6 +1,6 @@
import { defineConfig, mergeConfig } from "vitest/config";
import { playwright } from "@vitest/browser-playwright";
import { vitePluginsConfig } from "./vite.config.ts";
import { vitePluginsConfig } from "./vite.config";
import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
import path from "node:path";
import { fileURLToPath } from "node:url";