Files
OwnCord/Client/tests/e2e/helpers.ts
T
J3vbandClaude 9eba6969d2 B1-5: ownership moves (RL-09 / L-09, RL-10 / L-10, RL-11 / L-11, RL-13 / L-12) (#1417)
* refactor: move the protocol schema to protocol/schema.json (RL-09)

The WebSocket message-type schema is the one artifact in this repository that
neither component owns: `Server/ws/message_types.go` and
`Client/src/lib/protocolTypes.ts` are both generated from it, and neither may
be hand-edited. It nonetheless lived at `docs/protocol-schema.json` — filed
under the directory for prose, whose own README calls it "Reference" material
— and its generator lived at `Server/scripts/genprotocol/`, i.e. inside one of
the two consumers. Ownership was legible from neither location.

The obvious fix — move the generator to the repository root alongside the
schema, so the whole tool is at the cross-component boundary — is wrong here.
The generator is a Go `package main`, and Go modules are directory-rooted:
`Server/go.mod` roots at `Server/`, so a root-level Go program needs a second
module or a `go.work`. That second module would sit outside every path filter
this repository already has — `golangci-lint` runs with `working-directory:
Server/` (ci.yml), `go vet ./...` runs from `Server/` (scripts/run.mjs,
.githooks/pre-commit), `.githooks/pre-commit` selects Go files with
`^Server/.*\.go$`, `.githooks/pre-push` sets `server_changed` on `^Server/`,
setup-go caches on `Server/go.sum`, and dependabot has one gomod block for
`/Server`. Six gates would silently stop covering the generator, each failing
open. The schema is data and moves freely; the generator is Go and stays where
the Go toolchain already runs.

Done instead:
- `docs/protocol-schema.json` -> `protocol/schema.json`. A new top-level
  `protocol/` is the cross-component boundary, with a `README.md` naming the
  two generated consumers, the one command, and the four gates.
- `Server/scripts/genprotocol/` -> `Server/cmd/genprotocol/`, the module's
  conventional home for an executable. This also empties `Server/scripts/` of
  Go entry points except `seed.go`, which RL-10 moves next.
- `Server/cmd/` added to `Server/.dockerignore` and `Server/.air.toml`, which
  both already excluded `Server/scripts/`. Without this the move would have
  silently widened the Docker build context and the air watch set.

27 files, 115 insertions, 76 deletions. Two runtime path resolvers re-pointed
(`cmd/genprotocol/main.go:41` `-schema` default, `ws/protocol_contract_test.go:67`
`filepath.Join`); two git-hook grep patterns (`pre-commit:53`, `pre-push:57`);
eight generator call sites across five files (Makefile x2, scripts/run.mjs x2,
pre-commit x2, ci-check skill, bughunt-fix.js); two broken relative markdown
links (docs/README.md:47, docs/protocol.md:1497); two generated files
regenerated, header lines only, zero constants changed; two ledger prose hits
plus a `render-ledger.mjs` re-render. No new verify was written: the
regenerate-and-diff check is already enforced three times (CI `make
protocol-verify`, `.githooks/pre-commit`, `npm run check:server`) and
`ws/protocol_contract_test.go` independently checks the schema against the
constants a fourth time.

Verified: both directions, for both resolvers. With `protocol/schema.json`
removed, `go test ./ws/ -run TestProtocol` fails with `reading protocol schema
at /home/user/OwnCord/protocol/schema.json: no such file or directory` (two
tests) and `go run ./cmd/genprotocol` exits 1 with `read schema: open
../protocol/schema.json: no such file or directory`; with the file restored
both pass. So the new path is genuinely resolved, not merely spelled in a
comment. The hook patterns were exercised directly: the pre-commit pattern
matches `protocol/schema.json` and `Server/cmd/genprotocol/main.go` and no
longer matches `docs/protocol-schema.json`; the pre-push pattern matches
`protocol/schema.json`. `go run ./cmd/genprotocol` twice in a row leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the committed outputs are exactly what the generator emits.
`go build ./...` and `go vet ./...` pass; `npx prettier --check .`,
`npm run typecheck` and `npm run lint` pass; `node .superpowers/render-ledger.mjs
--check` reports 348 findings valid.

Not included: the four dated `docs/audit-*.md` files, the older
`docs/plans/*`, and `CHANGELOG.md` keep the old path — they are point-in-time
records, and `.prettierignore` and `scripts/check-doc-counts.mjs` already
treat them as deliberately unmaintained. The B1 plan itself keeps its own
wording, since it states intent rather than current state. `Server/scripts/`
is not deleted: it still holds `seed.go` (RL-10), `k6/`, `toxiproxy/` and two
shell scripts. `Server/telemetry/metrics.go:19` declares a scope for a
`Server/voice` package that does not exist — spotted here, unrelated to this
move, left for RL-13's sweep to carry forward verbatim rather than fixed
inside a relocation. No `seed:` Make target was added.

Refs RL-09, L-09

* refactor: move the seed tool under Server/cmd/seed (RL-10)

`Server/scripts/seed.go` was a `package main` sitting directly in
`Server/scripts/`, which made `Server/scripts` itself one of the module's
three main packages — a developer tool in the module's build graph under a
directory name that says "loose scripts". It also did filesystem work in
`func init()`: `os.MkdirAll("data", 0o750)` ran before `flag.Parse()`, so the
directory appeared even when the tool immediately refused to run.

The audit row (RL-10) claims that `init()` fires "during test discovery". It
does not, and the obvious fix aimed at that claim would be aimed at nothing:
`Server/scripts/` contains zero `_test.go` files, so Go never builds a test
binary there and `go test ./...` never runs the `init()`. The residual defect
is narrower and real — an untagged `package main` in the build graph, plus a
side effect on a path (`go run ./cmd/seed -h`) that has nothing to do with
tests.

Done:
- `Server/scripts/seed.go` -> `Server/cmd/seed/main.go`, joining
  `cmd/genprotocol/` from RL-09. `Server/scripts/` now holds shell and JS
  tooling only (docker-smoke.sh, k6/, toxiproxy/, voice-test.sh) and no Go
  entry point at all.
- The `os.MkdirAll` moved out of `init()` to immediately before `db.Open` in
  `main()` — the one call that needs the directory, since `db.Open` ->
  `OpenWithMaxReaders` -> `openFile` creates no intermediate directories.
- The package doc comment's usage lines were wrong in two ways, not one: they
  named `go run scripts/seed.go`, which no longer exists, and they omitted
  the mandatory `-confirm-dev`, so neither documented command could ever have
  run. Both corrected, and `seed.go is a standalone tool` became the
  conventional `Command seed populates ...`.
- `Server/CLAUDE.md`'s Layout list now names `cmd/` and states that no Go
  entry point lives in `scripts/`.

Two files, 20 insertions, 17 deletions. `go list` main packages go from
`{server, server/cmd/genprotocol, server/scripts}` to `{server,
server/cmd/genprotocol, server/cmd/seed}` — the count is unchanged at three,
which is the honest framing: this relocates a main package to a conventional
path, it does not remove one from the build graph.

Verified: both directions, by building the pre-change file and the
post-change file and running each in a fresh empty directory. Before, `seed`
with no flags exits 1 *and leaves a `data/` directory behind*; `seed -h`
exits 0 and also leaves `data/` behind. After, both exit the same way and
create nothing — `data/ exists=NO` in each case. The happy path is unchanged:
`seed -confirm-dev` in an empty directory creates `data/` at mode 0750,
writes `data/chatserver.db`, and reports 4 users / 5 channels / 31 messages;
a second run reports 0 new rows, so idempotence survives. The old documented
invocation now fails loudly (`go run scripts/seed.go` -> `stat
scripts/seed.go: no such file or directory`) and the new one is what the
comment says. All four build-tag variants compile, `go vet ./...` passes,
`gofmt -l` is clean outside `db/dbgen`, and `npx prettier --check .` passes.

Behaviour delta, called out rather than left silent: the two cases above
(`-h`, and a missing `-confirm-dev`) no longer create `./data`. That is a
change, not a pure relocation. It is the change RL-10 asks for — the remedy
text is "remove import/test-time filesystem side effects" — and the
alternative that preserves the old behaviour exactly, making the `MkdirAll`
the first statement of `main()` before `flag.Parse()`, would keep precisely
the side effect the item exists to remove.

Not included: `Server/scripts/genprotocol` was moved to `Server/cmd/` by the
RL-09 commit rather than here, so the "executable tooling under conventional
command ownership" class is closed across the two commits, not this one
alone. `filepath.Dir(*dbPath)` was evaluated for the `MkdirAll` and rejected:
it would fix a real gap (`-db /elsewhere/x.db` still creates a useless
`./data` and does not create `/elsewhere`) but it means creating an arbitrary
directory from CLI input, and that is a behaviour change past "shift it out
of `init()`" — worth its own item. No `make seed` target was added, and the
dated `docs/audit-*.md` rows naming `Server/scripts/seed.go` keep the old
path. The findings ledger has zero references to this file, so no re-render
was needed.

Refs RL-10, L-10

* test: give the cross-stack contracts a named tier (RL-11)

`Client/tests/unit/admin-static-channel-perms.test.ts` reads and executes
`Server/admin/static/index.html`. Filed under `tests/unit`, nothing about its
location or name said it locks a server-owned artifact, so a Go developer
editing the admin SPA got a red check called "Client Unit Tests" with no clue
why.

The register describes this as one file. It is not, and the measured set does
not match the description in either direction:
- Client -> Server: exactly ONE test crosses by filesystem read, not two.
  `main-page.test.ts` was named in the plan but only carries a prose comment
  citing `Server/admin/update_handlers.go:181` at line 1046 — no read, no
  import, nothing to move.
- Server -> Client: the four tests the plan named do not cross.
  `waf_test.go`/`waf_crs_test.go` set a `User-Agent: OwnCordClient/1.0`
  literal that appears nowhere under `Client/`; `ws_integration_test.go:289`
  and `sanitize_content_fuzz_test.go:46` are comments. The real crossing is
  one the register never named: `Server/updater/updater_test.go:630` does
  `os.ReadFile` on `Client/src-tauri/tauri.conf.json`.

The obvious fixes are both wrong. Moving the invariant "to the owning server
test" cannot work: `Server/go.mod` carries no JavaScript engine (no goja,
otto, v8go, quickjs, rogchap, duktape), so a Go port could only assert at the
text level like `admin/perm_grid_test.go` does — and that is not a
substitute. Flipping the guard at `admin/static/index.html:1182` to
`targetIsTouchedRole=false` reintroduces OC-0154 in full while leaving every
greppable identifier intact, so a text-level test passes on a broken file.
Relocating it to the e2e admin journey is worse: that job is
`continue-on-error: true` and deliberately unpinned ("requiring it is
theatre" — `docs/plans/b0-dev-branch-protection.sh`), so it would convert a
blocking, pinned gate into one that is green regardless. And the journey does
not cover the invariant today: `grep -Eic "perm|access|role|override|matrix"`
over its 142 lines returns 0, so the "if e2e already covers it, delete"
branch never fires.

Done — one tier, applied to the whole set, defined by artifact coupling and
placed by runtime capability:
- New `Client/tests/contract/`, holding
  `server-admin-static-channel-perms.test.ts`. Same directory depth, so
  `../../../Server/...` still resolves; the body is byte-identical apart from
  a header naming the owner and the runner.
- `Server/updater/tauri_key_contract_test.go` splits the one cross-component
  Go test out of `updater_test.go` verbatim, same `package updater`. It stays
  in Go — placement follows capability, and Go parses JSON fine — so only the
  file name has to declare the crossing. Without this the item would have
  been "moved one file and declared the class closed".
- `npm run test:contract`, and the tier, the membership rule and a
  blocking/non-blocking table in `docs/contributing.md#testing`, which
  previously described no tiers at all.
- `Client/CLAUDE.md`'s tier list was missing `tests/e2e/admin` and
  `tests/e2e/native` before this; it now lists all seven and states the rule.
  `Server/CLAUDE.md` records why the SPA's execution-level invariant is
  locked from the client tree, so nobody "fixes" it into a regex.
- Ledger `OC-0154.fix.test` re-pointed and `FINDINGS.md` re-rendered;
  `.claude/workflows/bughunt.js` — the workflow that produced OC-0154 — no
  longer describes the TS test surface as `tests/unit/*.test.ts` only.
- Three stale cross-stack pointers of exactly the class this item is about:
  `tests/e2e/helpers.ts:348,351` and `tests/unit/types.test.ts:13` named
  `docs/brain/06-Specs/PROTOCOL.md`, which does not exist (`docs/brain/` is a
  gitignored path); all now name `docs/protocol.md`.

15 files, 125 insertions, 33 deletions. No CI job, workflow, vitest,
tsconfig, eslint, knip or stryker change, and no new pinned check —
`ci.yml`'s `npx vitest run --coverage` has no path filter and
`vitest.config.ts` includes `tests/**/*.test.ts`, so enforcement after the
move is bit-identical to enforcement before it. That is deliberate: `dev`
pins 11 contexts and a 12th is a branch-protection API write, not something a
PR can do, so any new job would be advisory until someone separately changed
repository settings — strictly less protection than today.

Verified: both directions, and the assertion was not weakened. Flipping
`admin/static/index.html:1182` to `const targetIsTouchedRole=false;` makes
the moved test fail (`AssertionError: expected 'DELETE' not to be 'DELETE'`);
`git checkout` of that file makes it pass again — so the invariant survived
the move intact rather than becoming a test that passes anywhere. The split
Go test's cross-boundary read is live too: with
`Client/src-tauri/tauri.conf.json` moved away, `go test ./updater/` fails
with `ReadFile(../../Client/src-tauri/tauri.conf.json): no such file or
directory` from `tauri_key_contract_test.go:20`, and passes once restored.
The full client suite is 192 files / 5257 tests passing, identical to the
count before the move; `npm run typecheck` passes, which proves
`tests/contract/` is inside the tsconfig graph and that `tests/types/jsdom.d.ts`
still resolves the moved test's `import { JSDOM }`. `npm run lint`,
`npx prettier --check .`, `go vet ./...` and `go test ./updater/` all pass.
`git grep "tests/unit/admin-static-channel-perms"` finds no survivor outside
the B1 plan itself.

Not included: nothing was deleted, because no e2e sibling covers OC-0154.
`Client/tests/types/jsdom.d.ts` was neither moved nor deleted — it is still
the only type source for the moved test's `jsdom` import. `capabilities-scope.test.ts`
and `tauri-conf-webview2-args.test.ts` read `src-tauri/` and stay in
`tests/unit`: `src-tauri` is inside the `Client` component, so they are not
contract tests, and the rule earns that rather than hand-waving it — moving
them would have forced repoints of ledger entry OC-0089 and
`docs/security.md:64` for no gain. Each gained a one-line header saying why.
`Server/admin/perm_grid_test.go` and `emoji_section_test.go` read their own
package's embedded asset and are unchanged; they are the text-level
complement to the execution-level test, not duplicates. No JS engine was
added to `go.mod`, no npm root was created under `Server/`, and no root-level
`tests/` tier was created — there is no runner for one and no way to make it
blocking from a PR. Separately noticed and NOT fixed here:
`docs/contributing.md:221` still says "All ten required checks" while
`docs/plans/b0-dev-branch-protection.sh` pins eleven since B1-3 added
`Repository Hygiene`, and `docs/plans/hp-0-scorecard-2026-08-25.md:109` is
stale the same way — that is the branch-protection item's to fix, not this
one's, and one register item per commit.

Refs RL-11, L-11

* refactor: rename the Go module to github.com/J3vb/OwnCord/Server (RL-13)

`Server/go.mod` declared `github.com/owncord/server` while the public
repository is `github.com/J3vb/OwnCord`. Nothing resolves that path — there is
no `owncord` GitHub org and no vanity-import host serving go-import metadata
for it — so every import line in the tree named a location that does not
exist. It compiles because a main module's own path is never fetched, which is
exactly why it went unnoticed.

The obvious fix — an AST-aware import rewriter (`gomvpkg`, `go mod edit`) —
is wrong here, and provably so. Six of the 722 occurrences are not imports at
all: `api/main_test.go:20` (a goleak `IgnoreTopFunction` pattern),
`telemetry/metrics.go:17-19` (three OTel instrumentation-scope names),
`invariants/syncutil_locks.go:73` (a diagnostic message), and
`invariants/syncutil_locks_test.go:56` (an import line inside a raw-string Go
fixture). An import rewriter touches none of them, and the compiler cannot
see any of them either.

Done as one scripted substitution over `git ls-files`, anchored on the full
`github.com/owncord/server` string. The anchor matters: `owncord-server` is a
different identifier — the OTel `service.name` (`config/config.go`,
`telemetry/telemetry_otel.go`) and the GHCR image name
(`.github/workflows/release.yml`, `docker-compose.yml`) — and a looser pattern
would have moved it. It is untouched: 10 occurrences across 9 files, before
and after.

350 files, 728 insertions, 728 deletions. 722 occurrences in 344 Go files,
plus `go.mod:1`, the `sed` at `Makefile:67`, `Server/CLAUDE.md:3`,
`docs/architecture/server.md:5`, and the ledger pair
(`findings-ledger.json:3758` plus a `render-ledger.mjs` re-render of
`FINDINGS.md`). Zero in any workflow, zero in the Dockerfile, zero in
`Server/.golangci.yml` (no `local-prefixes`, `gci`, `importas` or `depguard`
rule keys on the module path, so import grouping is not configured anywhere).

The plan's blast-radius estimate missed one thing, and it is the one that
would have gone red: **gofmt**. `J` (0x4A) sorts before every lowercase
letter, so in the 36 files where a module-local import shares a contiguous
group with a third-party one, the module's imports must move above
`github.com/go-chi/...`. `gofmt -l` was clean before the substitution and
listed exactly 36 files after it; `gofmt -w` on those 36 restores it to
clean. `gofmt` is an enforced gate — the `formatters` block in
`Server/.golangci.yml`, which is S-05 — so a substitution-only commit fails
Lint.

Verified: both directions, and the line accounting is exact. Every added line
in this diff contains the new module path (728) and every removed line
contains the old one (728); the count of changed lines containing neither is
**zero**, so the gofmt re-sort moved module-path lines only and touched no
third-party import. The residual check
(`git ls-files -z | xargs -0 grep -n 'github\.com/owncord/server'`) returns
exactly two hits, both deliberately out of scope: the RL-13 row in
`docs/audit-2026-08-23-repository-layout.md` and the measurement row in this
phase's own plan. The compiler-invisible half was proven by reverting *only*
`api/main_test.go:20` to the old path on the otherwise-renamed tree:
`go build ./...` and `go vet ./api/` both still pass — they see nothing wrong
— while `go test ./api/` FAILS, because the runtime function name now carries
the new path and goleak stops ignoring `ws.(*Hub).Run.func1`. Restoring the
line makes it pass. `go.sum` is byte-identical (no `go mod tidy` was run and
none was needed). All four build-tag variants compile; `go vet ./...`,
`go vet -tags otel,wazero ./...` and `go vet -tags deadlock ./...` pass;
`go test -race ./...` is 16/16 packages green; `go test -tags deadlock ./...`
passes; the tag-gated `./plugin/...` (wazero) and `./telemetry/...` (otel)
runs pass. `golangci-lint` v2.11.3 — the pinned CI version, rebuilt locally
against Go 1.26 because the packaged binary cannot load a 1.26 config —
reports **0 issues**. `go run ./cmd/genprotocol` leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the rename does not reach the generated protocol constants.
`npx prettier --check .` and `node .superpowers/render-ledger.mjs --check`
pass.

Not included: `docs/audit-2026-08-23-repository-layout.md` and
`docs/plans/b1-repository-foundation-2026-08-25.md` keep the old path — they
are the audit row and the measurement that motivated this change, and
rewriting them would erase the record of what was measured. They are why the
residual check needs a two-path allowance rather than being empty; that
allowance is stated above rather than hidden in a pathspec.
`telemetry/metrics.go:19` declares `scopeVoice` for a `Server/voice` package
that does not exist; the substitution carried the dead path forward verbatim
as `github.com/J3vb/OwnCord/Server/voice` rather than fixing it, because
correcting a real observability bug inside a mechanical rename would hide it
in a 350-file diff. It needs its own item. No `go.work`, no second module,
and no vanity-import host was set up — the new path resolves against the real
repository, but nothing imports this module as a library, so `go get`
reachability was not exercised either way.

Refs RL-13, L-12

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-27 07:11:18 +02:00

1048 lines
36 KiB
TypeScript

/**
* Shared E2E test helpers — Tauri mock injection for browser-based testing.
*
* The app uses Tauri IPC through __TAURI_INTERNALS__.invoke:
* - HTTP: plugin:http|fetch → plugin:http|fetch_send → plugin:http|fetch_read_body
* - WS: ws_connect, ws_send, ws_disconnect + events ws-state, ws-message
* - Events: plugin:event|listen, plugin:event|unlisten
*/
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
// ---------------------------------------------------------------------------
// Mock data — basic
// ---------------------------------------------------------------------------
export const MOCK_TOKEN = "mock-session-token-abc123";
export const MOCK_LOGIN_RESPONSE = {
token: MOCK_TOKEN,
requires_2fa: false,
};
export const MOCK_LOGIN_2FA_RESPONSE = {
requires_2fa: true,
partial_token: "mock-partial-token",
};
export const MOCK_CHANNELS = [
{ id: 1, name: "general", type: "text", position: 0, category: null },
{ id: 2, name: "random", type: "text", position: 1, category: null },
];
export const MOCK_MESSAGES = {
messages: [
{
id: 101,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: "" },
content: "Hello world!",
timestamp: "2026-03-15T10:00:00Z",
edited_at: null,
attachments: [],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
],
has_more: false,
};
export const MOCK_ROLES = [
{ id: 1, name: "admin", color: "#ff0000", permissions: 0x40000000 },
{ id: 2, name: "moderator", color: "#00aaff", permissions: 0x1000000 },
{ id: 3, name: "member", color: null, permissions: 0x3 },
];
export const MOCK_READY_PAYLOAD = {
type: "ready",
payload: {
channels: MOCK_CHANNELS,
members: [
{ id: 1, username: "testuser", avatar: "", status: "online", role: "admin" },
{ id: 2, username: "otheruser", avatar: "", status: "online", role: "member" },
],
voice_states: [],
roles: MOCK_ROLES,
},
};
export const MOCK_AUTH_OK = {
type: "auth_ok",
payload: {
user: { id: 1, username: "testuser", avatar: "", role: "admin" },
server_name: "Test Server",
motd: "Welcome to the test server",
},
};
// ---------------------------------------------------------------------------
// Mock data — rich (for extended tests)
// ---------------------------------------------------------------------------
export const MOCK_CHANNELS_WITH_CATEGORIES = [
{ id: 1, name: "general", type: "text", position: 0, category: "Text Channels" },
{ id: 2, name: "random", type: "text", position: 1, category: "Text Channels" },
{ id: 3, name: "announcements", type: "text", position: 2, category: "Information" },
{ id: 10, name: "Voice Chat", type: "voice", position: 3, category: "Voice Channels" },
{ id: 11, name: "Music", type: "voice", position: 4, category: "Voice Channels" },
];
export const MOCK_MEMBERS_MULTI_ROLE = [
{ id: 1, username: "testuser", avatar: "", status: "online", role: "admin" },
{ id: 2, username: "moderator1", avatar: "", status: "online", role: "moderator" },
{ id: 3, username: "member1", avatar: "", status: "idle", role: "member" },
{ id: 4, username: "member2", avatar: "", status: "dnd", role: "member" },
{ id: 5, username: "offlineuser", avatar: "", status: "offline", role: "member" },
];
export const MOCK_MESSAGES_RICH = {
messages: [
{
id: 101,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: "" },
content: "Hello world!",
timestamp: "2026-03-15T10:00:00Z",
edited_at: null,
attachments: [],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
{
id: 102,
channel_id: 1,
user: { id: 2, username: "otheruser", avatar: "" },
content: "Hey @testuser, check this out!",
timestamp: "2026-03-15T10:01:00Z",
edited_at: null,
attachments: [],
reactions: [{ emoji: "\uD83D\uDC4D", count: 2, me: true }],
reply_to: null,
pinned: false,
deleted: false,
},
{
id: 103,
channel_id: 1,
user: { id: 2, username: "otheruser", avatar: "" },
content: "```js\nconsole.log('code block');\n```",
timestamp: "2026-03-15T10:01:30Z",
edited_at: null,
attachments: [],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
{
id: 104,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: "" },
content: "Replying to your message",
timestamp: "2026-03-15T10:02:00Z",
edited_at: "2026-03-15T10:02:30Z",
attachments: [],
reactions: [],
reply_to: 102,
pinned: false,
deleted: false,
},
{
id: 105,
channel_id: 1,
user: { id: 3, username: "member1", avatar: "" },
content: "Check this image",
timestamp: "2026-03-15T10:03:00Z",
edited_at: null,
attachments: [
{
id: "1",
filename: "screenshot.png",
size: 102400,
mime: "image/png",
url: "/uploads/screenshot.png",
},
],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
{
id: 106,
channel_id: 1,
user: { id: 3, username: "member1", avatar: "" },
content: "And this document",
timestamp: "2026-03-15T10:03:30Z",
edited_at: null,
attachments: [
{
id: "2",
filename: "report.pdf",
size: 512000,
mime: "application/pdf",
url: "/uploads/report.pdf",
},
],
reactions: [],
reply_to: null,
pinned: false,
deleted: false,
},
],
has_more: true,
};
// Remote users only — the ready payload must never claim the LOCAL user
// (id 1) is in a voice channel: the dispatcher treats "self in
// ready.voice_states while voiceStatus is idle" as stale state from a
// reload and immediately sends voice_leave + clears the local store
// (dispatcher.ts stale-voice cleanup), which would hide the widget again.
// Tests that need the widget visible must join via the real click path
// (see joinVoiceChannelByName).
export const MOCK_VOICE_STATE = [
{ user_id: 2, channel_id: 10, muted: true, deafened: false },
{ user_id: 3, channel_id: 10, muted: false, deafened: false },
];
export const MOCK_PINNED_MESSAGES = {
messages: [
{
id: 101,
channel_id: 1,
user: { id: 1, username: "testuser", avatar: "" },
content: "Hello world!",
timestamp: "2026-03-15T10:00:00Z",
pinned: true,
edited_at: null,
deleted: false,
reply_to: null,
attachments: [],
reactions: [],
},
],
has_more: false,
};
export const MOCK_INVITES = [
{
id: 1,
code: "abc123",
url: "https://localhost:8443/invite/abc123",
use_count: 3,
max_uses: 10,
expires_at: "2026-04-15T00:00:00Z",
},
{
id: 2,
code: "xyz789",
url: "https://localhost:8443/invite/xyz789",
use_count: 0,
max_uses: 1,
expires_at: null,
},
];
// ---------------------------------------------------------------------------
// Ready payload builders
// ---------------------------------------------------------------------------
function buildReadyPayload(overrides?: {
channels?: unknown[];
members?: unknown[];
voice_states?: unknown[];
roles?: unknown[];
dm_channels?: unknown[];
}): unknown {
return {
type: "ready",
payload: {
channels: overrides?.channels ?? MOCK_CHANNELS,
members: overrides?.members ?? MOCK_READY_PAYLOAD.payload.members,
voice_states: overrides?.voice_states ?? [],
roles: overrides?.roles ?? MOCK_ROLES,
dm_channels: overrides?.dm_channels ?? [],
},
};
}
// ---------------------------------------------------------------------------
// WS handler registry helpers
// ---------------------------------------------------------------------------
/**
* Chat echo handlers for E2E testing.
* Returns wsHandler entries that simulate server-side chat_send, chat_edit,
* and chat_delete echo responses.
*/
export function chatEchoHandlers(): Array<{ type: string; handler: string }> {
return [
{
type: "chat_send",
handler: `
var p = parsed.payload;
var echo = {
type: "chat_message",
payload: {
id: Date.now(),
channel_id: p.channel_id,
user: { id: 1, username: "testuser", avatar: "" },
content: p.content,
timestamp: new Date().toISOString(),
edited_at: null,
attachments: p.attachments || [],
reactions: [],
reply_to: p.reply_to || null,
pinned: false,
deleted: false
}
};
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify(echo));
}, 50);
`,
},
{
type: "chat_edit",
handler: `
var echo = {
type: "chat_edited",
payload: {
message_id: parsed.payload.message_id,
channel_id: parsed.payload.channel_id || 1,
content: parsed.payload.content,
edited_at: new Date().toISOString()
}
};
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify(echo));
}, 50);
`,
},
{
type: "chat_delete",
handler: `
var echo = {
type: "chat_deleted",
payload: {
message_id: parsed.payload.message_id,
channel_id: parsed.payload.channel_id || 1
}
};
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify(echo));
}, 50);
`,
},
];
}
/**
* Voice WS flow handlers for E2E testing.
* Simulates the server-side voice protocol defined in:
* docs/protocol.md (voice_join, voice_leave, voice_token, voice_token_refresh)
* protocol/schema.json (message type schemas)
*
* When docs/protocol.md voice message types change, update these handlers to match.
*/
export function voiceWsHandlers(): Array<{ type: string; handler: string }> {
return [
{
type: "voice_join",
handler: `
var p = parsed.payload;
// Remember the joined channel so the voice_leave echo can carry the
// real channel id, like the server does (dispatcher's self-leave
// teardown is gated on a channel match, so a wrong id is ignored).
globalThis.__mockVoiceChannel = p.channel_id;
setTimeout(function() {
// Full VoiceStatePayload shape — the server always sends username
// (and the flag fields); the sidebar renders user.username directly,
// so an omitted username breaks the voice-user list render.
__tauriEmitEvent("ws-message", JSON.stringify({
type: "voice_state",
payload: { user_id: 1, channel_id: p.channel_id, username: "testuser", muted: false, deafened: false, speaking: false, camera: false, screenshare: false }
}));
}, 50);
// Deliberately NO voice_token reply: a token makes the client start a
// real LiveKit session, which in the browser mock deterministically
// self-destructs (E2EE key exchange times out after ~15s, and
// Room.connect to the fake port fails after ~3 retries), tearing the
// widget down mid-test. These web tests validate the WS/UI layer only
// (see voice-lifecycle.spec.ts header); real LiveKit is covered by the
// native suite.
`,
},
{
type: "voice_leave",
handler: `
var ch = globalThis.__mockVoiceChannel || 0;
globalThis.__mockVoiceChannel = 0;
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify({
type: "voice_leave",
payload: { user_id: 1, channel_id: ch }
}));
}, 50);
`,
},
{
type: "voice_token_refresh",
handler: `
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify({
type: "voice_token",
payload: { token: "mock-livekit-token-refreshed", url: "ws://localhost:7880", channel_id: 0, direct_url: "" }
}));
}, 50);
`,
},
];
}
/**
* Voice join failure handler for E2E testing.
* Simulates a server error response when attempting to join a voice channel.
*/
export function voiceJoinFailureHandler(): { type: string; handler: string } {
return {
type: "voice_join",
handler: `
var p = parsed.payload;
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify({
type: "error",
payload: { code: "VOICE_JOIN_FAILED", message: "Failed to join voice channel" }
}));
}, 50);
`,
};
}
// ---------------------------------------------------------------------------
// Tauri mock script builder
// ---------------------------------------------------------------------------
export function buildTauriMockScript(opts: {
httpRoutes: Array<{ pattern: string; status: number; body: unknown }>;
simulateWsFlow: boolean;
echoChatSend?: boolean;
wsHandlers?: Array<{ type: string; handler: string }>;
readyOverrides?: {
channels?: unknown[];
members?: unknown[];
voice_states?: unknown[];
dm_channels?: unknown[];
};
/** Pinned peer identity keys served by get_identity_pin, keyed by userId
* (string). Absent key = null = "never pinned". */
identityPins?: Record<string, string>;
/** Make get_identity_pin REJECT — models a transient keyring failure, the
* DC-08 fail-closed path. */
identityPinError?: boolean;
/** Seeds the `get_settings` payload — pass `{"owncord:profiles": {schemaVersion, profiles}}`
* to give the connect page saved server profiles (e.g. one with
* autoConnect) instead of the default empty store. */
storedSettings?: Record<string, unknown>;
/** Seeds `load_credential`. Default null = nothing stored. `delete_credential`
* stays a no-op, so a test can assert behaviour that must hold even when the
* credential is still readable. */
storedCredential?: { username: string; token: string } | null;
}): string {
const readyPayload = buildReadyPayload(opts.readyOverrides);
// Merge explicit wsHandlers with auto-generated chat echo handlers
const allWsHandlers: Array<{ type: string; handler: string }> = [
...(opts.wsHandlers ?? []),
...(opts.echoChatSend ? chatEchoHandlers() : []),
];
return `
// -----------------------------------------------------------------------
// Event system
// -----------------------------------------------------------------------
const __eventListeners = {};
let __callbackId = 0;
function __tauriEmitEvent(eventName, payload) {
const listeners = __eventListeners[eventName] || [];
for (const { handler } of listeners) {
try { handler({ payload, event: eventName, id: 0 }); }
catch (e) { console.error("[tauri-mock] event error", eventName, e); }
}
}
window.__tauriEmitEvent = __tauriEmitEvent;
// Exposed so tests can wait until a listener is registered before
// emitting — registration goes through an async invoke roundtrip, so
// emitting straight after page load races it.
window.__tauriEventListeners = __eventListeners;
// Chronological record of every IPC invoke ({ cmd, args }) — lets tests
// assert side effects with no DOM footprint (pin writes, restart calls).
window.__invokeLog = [];
// -----------------------------------------------------------------------
// HTTP mock state
// -----------------------------------------------------------------------
const HTTP_ROUTES = ${JSON.stringify(opts.httpRoutes)};
let __nextRid = 1;
const __pendingFetch = {}; // rid → { url, route }
const __pendingBody = {}; // responseRid → Uint8Array (body bytes)
let __bodyRead = {}; // responseRid → boolean (already read)
// Sort routes by pattern length (longest first) to match most specific route
HTTP_ROUTES.sort((a, b) => b.pattern.length - a.pattern.length);
function matchRoute(url) {
for (const route of HTTP_ROUTES) {
if (url.includes(route.pattern)) return route;
}
return null;
}
// -----------------------------------------------------------------------
// __TAURI_INTERNALS__
// -----------------------------------------------------------------------
window.__TAURI_INTERNALS__ = {
metadata: {
currentWindow: { label: "main" },
currentWebview: { label: "main" },
},
transformCallback: (callback, once) => {
const id = __callbackId++;
if (typeof callback === "function") {
window["__tcb_" + id] = callback;
}
return id;
},
invoke: async (cmd, args) => {
// Every IPC call is recorded so tests can assert side effects that
// have no DOM footprint (e.g. store_identity_pin, plugin:process|restart).
window.__invokeLog.push({ cmd, args });
// ---- Events ----
if (cmd === "plugin:event|listen") {
const eventName = args?.event;
const handlerId = args?.handler;
const cb = window["__tcb_" + handlerId];
if (eventName && cb) {
if (!__eventListeners[eventName]) __eventListeners[eventName] = [];
__eventListeners[eventName].push({ id: handlerId, handler: cb });
}
return handlerId || 0;
}
if (cmd === "plugin:event|unlisten") return;
// ---- HTTP: fetch (step 1 — register request, return rid) ----
if (cmd === "plugin:http|fetch") {
const url = args?.clientConfig?.url || args?.url || "";
const rid = __nextRid++;
const route = matchRoute(url);
__pendingFetch[rid] = { url, route };
return rid;
}
// ---- HTTP: fetch_send (step 2 — return status + headers) ----
if (cmd === "plugin:http|fetch_send") {
const rid = args?.rid;
const pending = __pendingFetch[rid];
delete __pendingFetch[rid];
const responseRid = __nextRid++;
if (pending?.route) {
const bodyStr = JSON.stringify(pending.route.body);
const encoder = new TextEncoder();
const bodyBytes = encoder.encode(bodyStr);
__pendingBody[responseRid] = bodyBytes;
__bodyRead[responseRid] = false;
return {
status: pending.route.status,
statusText: pending.route.status === 200 ? "OK" : "Error",
url: pending.url,
headers: [["content-type", "application/json"]],
rid: responseRid,
};
}
// No matching route — 404
const fallback = JSON.stringify({ error: "NOT_FOUND", message: "mocked 404" });
const encoder = new TextEncoder();
__pendingBody[responseRid] = encoder.encode(fallback);
__bodyRead[responseRid] = false;
return {
status: 404,
statusText: "Not Found",
url: pending?.url || "",
headers: [["content-type", "application/json"]],
rid: responseRid,
};
}
// ---- HTTP: fetch_read_body (step 3 — return body bytes) ----
if (cmd === "plugin:http|fetch_read_body") {
const rid = args?.rid;
const body = __pendingBody[rid];
if (body && !__bodyRead[rid]) {
__bodyRead[rid] = true;
const result = Array.from(body);
result.push(0); // 0 = not end yet
return result;
}
// End signal: [1]
delete __pendingBody[rid];
delete __bodyRead[rid];
return [1];
}
// ---- HTTP: cancel ----
if (cmd === "plugin:http|fetch_cancel" || cmd === "plugin:http|fetch_cancel_body") {
return;
}
// ---- WS commands ----
if (cmd === "ws_connect") {
${
opts.simulateWsFlow
? `
setTimeout(() => __tauriEmitEvent("ws-state", "open"), 100);
`
: ""
}
return;
}
if (cmd === "ws_send") {
${
opts.simulateWsFlow
? `
try {
var parsed = JSON.parse(args?.message || "{}");
if (parsed.type === "auth") {
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify(${JSON.stringify(MOCK_AUTH_OK)}));
}, 100);
setTimeout(function() {
__tauriEmitEvent("ws-message", JSON.stringify(${JSON.stringify(readyPayload)}));
}, 200);
}
var WS_HANDLERS = ${JSON.stringify(allWsHandlers)};
for (var i = 0; i < WS_HANDLERS.length; i++) {
var h = WS_HANDLERS[i];
if (parsed.type === h.type) {
(new Function('parsed', '__tauriEmitEvent', h.handler))(parsed, __tauriEmitEvent);
}
}
} catch (e) {}
`
: ""
}
return;
}
if (cmd === "ws_disconnect") return;
// ---- HTTP TOFU proxy ----
// api.ts routes all REST calls through the Rust loopback proxy:
// baseUrl() awaits start_http_proxy and builds
// http://127.0.0.1:{port}/api/v1/... — if this returns null (the
// unhandled-command fallback), the URL gets a literal "null" port and
// Request construction throws before the plugin:http mock above is
// ever consulted, failing every login. Any numeric port works: the
// transport is still plugin:http|fetch and route matching is
// substring-based, so the fake origin never has to be listened on.
if (cmd === "start_http_proxy") return 45123;
if (cmd === "stop_http_proxy") return;
// ---- LiveKit proxy ----
if (cmd === "start_livekit_proxy") return { port: 7880 };
if (cmd === "stop_livekit_proxy") return;
// ---- Credentials ----
if (cmd === "save_credential" || cmd === "delete_credential") return null;
if (cmd === "load_credential") return ${JSON.stringify(opts.storedCredential ?? null)};
// ---- Settings ----
if (cmd === "get_settings") return ${JSON.stringify(opts.storedSettings ?? {})};
if (cmd === "save_settings") return;
// ---- Certs ----
if (cmd === "store_cert_fingerprint" || cmd === "get_cert_fingerprint") return null;
if (cmd === "accept_cert_fingerprint") return null;
// ---- E2EE identity (keyring blob + TOFU pins) ----
// null = "no stored key/pin". ensureIdentityKeyPublished on the ready
// event is fire-and-forget (void), so a null store is safe and just
// exercises the fresh-key path. Pins are configurable per test:
// identityPins seeds get_identity_pin per userId, identityPinError
// makes the read REJECT (the DC-08 "store unreadable" path).
if (cmd === "save_identity_key" || cmd === "load_identity_key" || cmd === "delete_identity_key") return null;
if (cmd === "get_identity_pin") {
${
opts.identityPinError === true
? `throw new Error("keyring unavailable (mock)");`
: `window.__mockIdentityPins ??= ${JSON.stringify(opts.identityPins ?? {})};
return window.__mockIdentityPins[String(args?.userId)] ?? null;`
}
}
// A pin write must be visible to the next read, exactly as the real
// keyring is: re-pinning a mismatched peer replays the announce that
// was blocked and re-verifies it against the pin just stored
// (OC-0212). A no-op store would serve the stale pin straight back,
// so the replay would re-fail and the mock would report a permanent
// mismatch the real keyring never produces.
if (cmd === "store_identity_pin") {
window.__mockIdentityPins ??= ${JSON.stringify(opts.identityPins ?? {})};
window.__mockIdentityPins[String(args?.userId)] = args?.pin ?? null;
return null;
}
// ---- Window/webview plugin stubs ----
if (cmd.startsWith("plugin:window|") || cmd.startsWith("plugin:webview|")) return null;
console.log("[tauri-mock] unhandled invoke:", cmd);
return null;
},
convertFileSrc: (path) => path,
};
`;
}
// ---------------------------------------------------------------------------
// Public API — mock injection
// ---------------------------------------------------------------------------
export async function mockTauriConnect(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
],
simulateWsFlow: false,
}),
);
}
export async function mockTauriConnectWith2FA(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_2FA_RESPONSE },
],
simulateWsFlow: false,
}),
);
}
export async function mockTauriFullSession(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
],
simulateWsFlow: true,
}),
);
}
/**
* Full session where the connect page ALSO has a saved auto-connect profile
* for the same host, and `load_credential` still returns a usable credential.
* `delete_credential` remains a no-op, so a logout test built on this asserts
* the client refuses to auto-login on its own account rather than relying on
* the credential delete having already won a race.
*/
export async function mockTauriFullSessionWithAutoConnect(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
],
simulateWsFlow: true,
storedCredential: { username: "testuser", token: "stored-token" },
storedSettings: {
"owncord:profiles": {
schemaVersion: 1,
profiles: [
{
id: "p1",
name: "Local",
host: "localhost:8443",
username: "testuser",
autoConnect: true,
rememberPassword: true,
color: "#5865f2",
lastConnected: null,
},
],
},
},
}),
);
}
export async function mockTauriFullSessionWithMessages(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES_RICH },
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
{ pattern: "/api/v1/invites", status: 200, body: MOCK_INVITES },
],
simulateWsFlow: true,
readyOverrides: {
channels: MOCK_CHANNELS_WITH_CATEGORIES,
members: MOCK_MEMBERS_MULTI_ROLE,
},
}),
);
}
export async function mockTauriFullSessionWithVoice(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
],
simulateWsFlow: true,
wsHandlers: voiceWsHandlers(),
readyOverrides: {
channels: MOCK_CHANNELS_WITH_CATEGORIES,
members: MOCK_MEMBERS_MULTI_ROLE,
voice_states: MOCK_VOICE_STATE,
},
}),
);
}
export async function mockTauriFullSessionWithVoiceFailure(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
],
simulateWsFlow: true,
wsHandlers: [voiceJoinFailureHandler()],
readyOverrides: {
channels: MOCK_CHANNELS_WITH_CATEGORIES,
members: MOCK_MEMBERS_MULTI_ROLE,
voice_states: MOCK_VOICE_STATE,
},
}),
);
}
export async function mockTauriFullSessionWithEcho(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
],
simulateWsFlow: true,
echoChatSend: true,
}),
);
}
export async function mockTauriFullSessionWithMessagesAndEcho(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES_RICH },
{ pattern: "/pins", status: 200, body: MOCK_PINNED_MESSAGES },
{ pattern: "/api/v1/invites", status: 200, body: MOCK_INVITES },
],
simulateWsFlow: true,
echoChatSend: true,
readyOverrides: {
channels: MOCK_CHANNELS_WITH_CATEGORIES,
members: MOCK_MEMBERS_MULTI_ROLE,
},
}),
);
}
export async function mockTauriFullSessionWithFailingMessages(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
{
pattern: "/messages",
status: 500,
body: { error: "INTERNAL_ERROR", message: "Failed to load messages" },
},
],
simulateWsFlow: true,
}),
);
}
export async function mockTauriLoginError(page: Page): Promise<void> {
await page.addInitScript(
buildTauriMockScript({
httpRoutes: [
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
{
pattern: "/api/v1/auth/login",
status: 401,
body: { error: "INVALID_CREDENTIALS", message: "Invalid username or password" },
},
],
simulateWsFlow: false,
}),
);
}
// ---------------------------------------------------------------------------
// Public API — page actions
// ---------------------------------------------------------------------------
export async function submitLogin(page: Page): Promise<void> {
await page.locator("#host").fill("localhost:8443");
await page.locator("#username").fill("testuser");
await page.locator("#password").fill("password123");
await page.locator("button.btn-primary[type='submit']").click();
}
/**
* Login and wait for the main app layout to appear.
*/
export async function navigateToMainPage(page: Page): Promise<void> {
await submitLogin(page);
const appLayout = page.locator("[data-testid='app-layout']");
await expect(appLayout).toBeVisible({ timeout: 15_000 });
}
/**
* Open the settings overlay via the user bar gear button.
*/
export async function openSettings(page: Page): Promise<void> {
const settingsBtn = page.locator("button[aria-label='Settings']");
await settingsBtn.click();
const overlay = page.locator("[data-testid='settings-overlay']");
await expect(overlay).toHaveClass(/open/, { timeout: 5_000 });
}
/**
* Switch to a settings tab by name.
*/
export async function switchSettingsTab(page: Page, tabName: string): Promise<void> {
const tab = page.locator(".settings-sidebar button.settings-nav-item", { hasText: tabName });
await tab.click();
await expect(tab).toHaveClass(/active/);
}
/**
* Emit a WebSocket event from the mock server to the client.
* Must be called after the page has loaded and WS listeners are registered.
*/
export async function emitWsEvent(page: Page, eventName: string, payload: unknown): Promise<void> {
await page.evaluate(
({ event, data }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).__tauriEmitEvent(
event,
typeof data === "string" ? data : JSON.stringify(data),
);
},
{ event: eventName, data: payload },
);
}
/**
* Emit a WS message event (shorthand for ws-message).
*/
export async function emitWsMessage(page: Page, message: unknown): Promise<void> {
await emitWsEvent(page, "ws-message", JSON.stringify(message));
}
// ---------------------------------------------------------------------------
// Anti-flakiness utilities
// ---------------------------------------------------------------------------
/**
* Wait for the WS mock to finish its auth + ready handshake.
* The mock uses setTimeout(100ms) for ws-state open, then
* setTimeout(100/200ms) for auth_ok/ready — so the handshake
* completes within ~300ms. This helper polls for a reliable
* DOM signal instead of relying on hardcoded delays.
*/
export async function waitForWsReady(page: Page): Promise<void> {
// The channel sidebar populates after the ready payload is processed.
// Wait for the first channel item as proof the WS flow completed.
await expect(page.locator(".channel-item").first()).toBeVisible({ timeout: 10_000 });
}
/**
* Navigate to main page and wait for WS ready handshake to complete.
* Combines login + WS readiness in one call to reduce boilerplate
* and ensure tests start from a stable state.
*/
export async function navigateToMainPageReady(page: Page): Promise<void> {
await navigateToMainPage(page);
await waitForWsReady(page);
}
/**
* Join a voice channel through the real click path and wait for the voice
* widget to become visible. This is the only supported way for tests to get
* the local user into voice: pre-seeding the ready payload with user 1 no
* longer works (the dispatcher's stale-voice cleanup immediately leaves).
*/
export async function joinVoiceChannelByName(
page: Page,
channelName = "Voice Chat",
): Promise<void> {
await page.locator(".channel-item.voice", { hasText: channelName }).click();
await expect(page.locator("[data-testid='voice-widget']")).toHaveClass(/visible/, {
timeout: 10_000,
});
}
/**
* Emit a WS message and wait for a DOM change to confirm it was processed.
* Prevents flakiness from tests asserting before the message handler runs.
*
* @param page - Playwright page
* @param message - WS message payload to emit
* @param confirmLocator - Locator that should become visible/attached after processing
* @param timeout - Max wait time (default 5000ms)
*/
export async function emitWsMessageAndWait(
page: Page,
message: unknown,
confirmLocator: ReturnType<Page["locator"]>,
timeout = 5_000,
): Promise<void> {
await emitWsMessage(page, message);
await expect(confirmLocator).toBeVisible({ timeout });
}