Skip to content

Commit 09959b1

Browse files
committed
Drive the Playwright E2E smoke job across two connectors
The vitest smoke suite already exercises ConnectorManager against two sync-servers, but the only layer driving the real browser/UI (the Playwright E2E smoke) still linked a single connector. Add a spec that onboards against server A, adds server B through Settings -> Sync sources -> Add another, and asserts both peers render on the merged dashboard. Two distinct peers (one per server) is the strongest UI signal: peer B's card only appears if connector B refreshed and decrypted. The add-source flow's nested Onboarding does not manage data-screenshot-ready, so the helper waits on connector-card count instead. Bootstrap three independent peer files so the smoke and multi specs never redeem the same one-time-use share code.
1 parent 205318c commit 09959b1

6 files changed

Lines changed: 201 additions & 18 deletions

File tree

.github/workflows/code-checks.yml

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -125,42 +125,66 @@ jobs:
125125
e2e-smoke:
126126
name: E2E smoke
127127
runs-on: ubuntu-22.04
128-
timeout-minutes: 15
128+
timeout-minutes: 20
129129
# Mirrors the release-tag.yml e2e job at lower cost: bootstrap a fake phone
130130
# peer, build the SPA, drive it through Playwright with no screenshots.
131131
# Catches selector/bootstrap drift on every PR/main push so a release tag
132-
# never surfaces these surprises for the first time.
132+
# never surfaces these surprises for the first time. A second sync-server
133+
# backs the multi-connector spec (link the SPA to two servers via the UI).
133134
services:
134135
sync-server:
135136
image: ghcr.io/d4rken-org/octi-server@sha256:3829efba5ca5a4d407a0d0a048b8d0c20264ad2f9e389aa087c857ea0d0bddaa
136137
ports:
137138
- 18080:8080
138139
env:
139140
OCTI_CORS_ALLOWED_ORIGINS: 'http://127.0.0.1:4173,http://localhost:4173'
141+
sync-server-b:
142+
image: ghcr.io/d4rken-org/octi-server@sha256:3829efba5ca5a4d407a0d0a048b8d0c20264ad2f9e389aa087c857ea0d0bddaa
143+
ports:
144+
- 18081:8080
145+
env:
146+
OCTI_CORS_ALLOWED_ORIGINS: 'http://127.0.0.1:4173,http://localhost:4173'
140147
steps:
141148
- name: Checkout source
142149
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
143150
with: { persist-credentials: false }
144151
- name: Setup environment
145152
uses: ./.github/actions/common-setup
146-
- name: Wait for sync-server
153+
- name: Wait for sync-servers
147154
run: |
148155
set -euo pipefail
149-
for attempt in $(seq 1 30); do
150-
if curl -sf http://127.0.0.1:18080/v1/status > /dev/null; then
151-
echo "sync-server up after ${attempt}s"
152-
exit 0
153-
fi
154-
sleep 1
156+
for port in 18080 18081; do
157+
for attempt in $(seq 1 30); do
158+
if curl -sf "http://127.0.0.1:${port}/v1/status" > /dev/null; then
159+
echo "sync-server on ${port} up after ${attempt}s"
160+
continue 2
161+
fi
162+
sleep 1
163+
done
164+
echo "sync-server on ${port} did not respond within 30s" >&2
165+
docker logs ${{ job.services.sync-server.id }} || true
166+
docker logs ${{ job.services['sync-server-b'].id }} || true
167+
exit 1
155168
done
156-
echo "sync-server did not respond within 30s" >&2
157-
docker logs ${{ job.services.sync-server.id }} || true
158-
exit 1
159-
- name: Bootstrap fake phone peer
169+
# Three bootstrap files with independent one-time-use share codes:
170+
# bootstrap-peer.json (server A) — the single-connector smoke spec
171+
# peer-a.json (server A), peer-b.json (server B) — the multi-connector spec
172+
# Separate files so the two specs never redeem the same code.
173+
- name: Bootstrap fake phone peer (smoke, server A)
160174
env:
161175
SYNC_SERVER_URL: http://127.0.0.1:18080
162176
OUTPUT_PATH: bootstrap-peer.json
163177
run: pnpm bootstrap-peer
178+
- name: Bootstrap fake phone peer (multi, server A)
179+
env:
180+
SYNC_SERVER_URL: http://127.0.0.1:18080
181+
OUTPUT_PATH: peer-a.json
182+
run: pnpm bootstrap-peer
183+
- name: Bootstrap fake phone peer (multi, server B)
184+
env:
185+
SYNC_SERVER_URL: http://127.0.0.1:18081
186+
OUTPUT_PATH: peer-b.json
187+
run: pnpm bootstrap-peer
164188
- name: Install Playwright browsers
165189
run: pnpm exec playwright install --with-deps chromium
166190
- name: Build SPA (stable channel for smoke)
@@ -172,9 +196,16 @@ jobs:
172196
env:
173197
BOOTSTRAP_PEER_FILE: bootstrap-peer.json
174198
run: pnpm e2e:smoke
199+
- name: Run E2E multi-connector
200+
env:
201+
BOOTSTRAP_PEER_A_FILE: peer-a.json
202+
BOOTSTRAP_PEER_B_FILE: peer-b.json
203+
run: pnpm e2e:multi
175204
- name: Dump sync-server logs on failure
176205
if: failure()
177-
run: docker logs ${{ job.services.sync-server.id }} || true
206+
run: |
207+
docker logs ${{ job.services.sync-server.id }} || true
208+
docker logs ${{ job.services['sync-server-b'].id }} || true
178209
- name: Upload Playwright trace on failure
179210
if: failure()
180211
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"bootstrap-peer": "tsx tools/screenshots/bootstrap-peer.ts",
1818
"e2e": "playwright test tests/e2e/link-and-capture.spec.ts",
1919
"e2e:smoke": "playwright test tests/e2e/smoke.spec.ts",
20+
"e2e:multi": "playwright test tests/e2e/multi-connector.spec.ts",
2021
"screenshots:thumbs": "node scripts/generate-thumbs.mjs"
2122
},
2223
"dependencies": {

src/ui/LinkPaste.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,6 @@
8787
</div>
8888

8989
{#if error}
90-
<p style="margin-top: 0.75rem; color: #ff8a8a;">{error}</p>
90+
<p data-testid="paste-error" style="margin-top: 0.75rem; color: #ff8a8a;">{error}</p>
9191
{/if}
9292
</section>

tests/e2e/helpers.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,53 @@ export async function linkViaPaste(page: Page, linkingDataBlob: string): Promise
102102
await page.locator('[data-testid="paste-submit"]').click();
103103
await waitForScreenReady(page, "dashboard");
104104
}
105+
106+
/**
107+
* Link a *second* (or further) sync source from inside the dashboard:
108+
* Settings → Sync sources → Add another → paste flow.
109+
*
110+
* Unlike {@link linkViaPaste}, the Add-Source sheet hosts an `<Onboarding>` with
111+
* `manageScreenshotMarker={false}`, so there is no `data-screenshot-ready`
112+
* transition to await. Success is observed by the Sync Sources list (mounted
113+
* behind the add sheet) growing to `expectedConnectorCount` cards; a join
114+
* failure is surfaced fast via LinkPaste's inline error instead of timing out.
115+
*/
116+
export async function addSyncSourceViaPaste(
117+
page: Page,
118+
linkingDataBlob: string,
119+
expectedConnectorCount: number,
120+
): Promise<void> {
121+
await page.locator('[data-testid="nav-settings"]').click();
122+
await waitForScreenReady(page, "settings");
123+
await page.locator('[data-testid="settings-open-sources"]').click();
124+
await page.locator('[data-testid="add-sync-source"]').click();
125+
await page.locator('[data-testid="onboarding-paste"]').click();
126+
await page.locator('[data-testid="paste-textarea"]').fill(linkingDataBlob);
127+
await page.locator('[data-testid="paste-submit"]').click();
128+
129+
const connectorCards = page.locator('[data-testid="connector-card"]');
130+
const pasteError = page.locator('[data-testid="paste-error"]');
131+
// Race the success signal (new card appears) against the failure signal
132+
// (inline error). `.catch` collapses each loser's timeout into a sentinel so
133+
// the pending branch never surfaces as an unhandled rejection.
134+
const outcome = await Promise.race([
135+
connectorCards
136+
.nth(expectedConnectorCount - 1)
137+
.waitFor({ state: "visible" })
138+
.then(() => "linked" as const)
139+
.catch(() => "timeout" as const),
140+
pasteError
141+
.waitFor({ state: "visible" })
142+
.then(() => "error" as const)
143+
.catch(() => "no-error" as const),
144+
]);
145+
if (outcome === "error") {
146+
throw new Error(
147+
`Add sync source failed: ${(await pasteError.textContent())?.trim() || "(empty error)"}`,
148+
);
149+
}
150+
await expect(
151+
connectorCards,
152+
`${expectedConnectorCount} connector cards after adding sync source`,
153+
).toHaveCount(expectedConnectorCount);
154+
}

tests/e2e/multi-connector.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { expect, test } from "@playwright/test";
2+
import { blobForProject, loadBootstrapPeerFrom } from "./peer";
3+
import {
4+
addSyncSourceViaPaste,
5+
linkViaPaste,
6+
waitForScreenReady,
7+
watchUncaughtErrors,
8+
} from "./helpers";
9+
import { octiServerConnectorId } from "../../src/protocol/connector-id";
10+
import { serverBaseUrl } from "../../src/protocol/models";
11+
12+
/**
13+
* Real-browser multi-connector E2E. The vitest smoke suite
14+
* (`src/__smoke__/multi-connector.test.ts`) drives `ConnectorManager` directly;
15+
* this drives the actual UI:
16+
*
17+
* 1. Onboard against server A (paste-link peer A) → dashboard, peer A visible.
18+
* 2. Add server B from Settings → Sync sources → Add another (paste-link peer B).
19+
* 3. Both connectors are listed; both peers render on the merged dashboard.
20+
*
21+
* Two distinct peers (one per server) is the clearest "multiple connectors all
22+
* render in the real UI" signal — peer B's card only appears if connector B
23+
* refreshed AND decrypted, so it subsumes a per-connector health check.
24+
* Same-peer-on-both-servers dedupe is already covered by the vitest smoke.
25+
*
26+
* Needs two bootstrap files, one per server — see the `e2e-smoke` CI job.
27+
*/
28+
29+
const PEER_A_FILE = process.env.BOOTSTRAP_PEER_A_FILE ?? "peer-a.json";
30+
const PEER_B_FILE = process.env.BOOTSTRAP_PEER_B_FILE ?? "peer-b.json";
31+
32+
test("link two connectors via UI + merged dashboard", async ({ page }, testInfo) => {
33+
// Two real joins plus refresh cycles against two servers — comfortably above
34+
// the 60s config default.
35+
test.setTimeout(120_000);
36+
37+
const peerA = loadBootstrapPeerFrom(PEER_A_FILE);
38+
const peerB = loadBootstrapPeerFrom(PEER_B_FILE);
39+
40+
// Guard against a CI miswiring (both files pointing at the same server): the
41+
// connector-id encodes only domain + accountId, and both CI servers share
42+
// 127.0.0.1, so a same-server mistake wouldn't be obvious downstream.
43+
expect(
44+
serverBaseUrl(peerA.serverAddress),
45+
"peer A and peer B must live on different sync-servers",
46+
).not.toBe(serverBaseUrl(peerB.serverAddress));
47+
48+
const blobA = blobForProject(peerA, testInfo.project.name);
49+
const blobB = blobForProject(peerB, testInfo.project.name);
50+
const assertNoUncaught = watchUncaughtErrors(page);
51+
52+
// 1. Onboard against server A.
53+
await page.goto("/");
54+
await waitForScreenReady(page, "onboarding");
55+
await linkViaPaste(page, blobA);
56+
await expect(
57+
page.locator(`[data-testid="device-card"][data-device-id="${peerA.deviceId}"]`),
58+
"peer A visible after first link",
59+
).toBeVisible();
60+
61+
// 2. Add server B through the in-dashboard add-source flow.
62+
await addSyncSourceViaPaste(page, blobB, 2);
63+
64+
// Both connectors are listed, identified by their wire connector-id
65+
// (kserver-<domain>-<accountId>) — distinguishes the two servers by account
66+
// since both share 127.0.0.1 in CI.
67+
const expectedIds = [
68+
octiServerConnectorId(peerA.serverAddress, peerA.accountId),
69+
octiServerConnectorId(peerB.serverAddress, peerB.accountId),
70+
].sort();
71+
const actualIds = await page
72+
.locator('[data-testid="connector-card"]')
73+
.evaluateAll((cards) => cards.map((c) => c.getAttribute("data-connector-id")));
74+
expect(actualIds.filter((id): id is string => id !== null).sort()).toEqual(expectedIds);
75+
76+
// 3. Reload to a clean dashboard (no stacked Settings/Sources sheets occluding
77+
// the grid; the persisted credentials re-bootstrap both connectors) and
78+
// assert both peers render on the merged dashboard.
79+
await page.goto("/");
80+
await waitForScreenReady(page, "dashboard");
81+
82+
await expect(
83+
page.locator(`[data-testid="device-card"][data-device-id="${peerA.deviceId}"]`),
84+
"peer A visible on merged dashboard",
85+
).toBeVisible();
86+
await expect(
87+
page.locator(`[data-testid="device-card"][data-device-id="${peerB.deviceId}"]`),
88+
"peer B visible on merged dashboard",
89+
).toBeVisible();
90+
await expect(page.locator(".banner.err"), "no decode-error banner").toHaveCount(0);
91+
92+
assertNoUncaught();
93+
});

tests/e2e/peer.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,26 @@ export interface BootstrapPeer {
1616
deviceId: string;
1717
}
1818

19-
export function loadBootstrapPeer(): BootstrapPeer {
20-
const path = process.env.BOOTSTRAP_PEER_FILE ?? "bootstrap-peer.json";
19+
/**
20+
* Load a bootstrap-peer file from an explicit path. The multi-connector spec
21+
* loads two distinct files (one peer per sync-server); the error message names
22+
* the actual file so a missing/empty bootstrap is unambiguous in CI logs.
23+
*/
24+
export function loadBootstrapPeerFrom(path: string): BootstrapPeer {
2125
const peer = JSON.parse(readFileSync(path, "utf8")) as BootstrapPeer;
2226
if (!Array.isArray(peer.linkingDataBlobs) || peer.linkingDataBlobs.length === 0) {
2327
throw new Error(
24-
`bootstrap-peer.json must contain a non-empty linkingDataBlobs array. ` +
28+
`${path} must contain a non-empty linkingDataBlobs array. ` +
2529
`Re-run bootstrap-peer with SHARE_CODES_COUNT >= the number of Playwright projects.`,
2630
);
2731
}
2832
return peer;
2933
}
3034

35+
export function loadBootstrapPeer(): BootstrapPeer {
36+
return loadBootstrapPeerFrom(process.env.BOOTSTRAP_PEER_FILE ?? "bootstrap-peer.json");
37+
}
38+
3139
/**
3240
* Pick the share-code blob for the current Playwright project. Returns by
3341
* index so two projects never collide on the same one-time-use code.

0 commit comments

Comments
 (0)