mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-19 23:52:26 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89856df37f | ||
|
|
d3a2f5deda |
+1
-1
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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%"]
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-28
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Generated
+22
-22
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<
|
||||
{
|
||||
|
||||
@@ -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-";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user