Skip to content

Commit f8c0ddc

Browse files
committed
feat(e2e): add nix E2E integration + fix collab disconnect + fix build manifest
- Add test-e2e nix check derivation with offline Playwright deps (bun2nix) - Skip Chromium in nix sandbox (missing shared libs), run Firefox only - Add OFFLINE_FLAGS (--no-mdns --no-relay --no-gossip) for nix sandbox - Fix collab.ts disconnect: add intentionalClose flag to prevent reconnect after explicit ws.close() (close handshake can fail/timeout causing code 1006 instead of 1000) - Fix build-manifest.ts: use mtime to pick newest bundle instead of last-alphabetical, clean stale JS bundles - Fix multi-user collab tests to use baseURL from Playwright fixture - Add bun2nix-e2e recipe to justfile 72 passed, 1 flaky (Firefox WS connect timing), 1 skipped (Chromium in nix)
1 parent 6098d09 commit f8c0ddc

File tree

7 files changed

+255
-82
lines changed

7 files changed

+255
-82
lines changed

pkgs/id/e2e/bun.nix

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Autogenerated by `bun2nix`, editing manually is not recommended
2+
#
3+
# Set of Bun packages to install
4+
#
5+
# Consume this with `fetchBunDeps` (recommended)
6+
# or `pkgs.callPackage` if you wish to handle
7+
# it manually.
8+
{
9+
copyPathToStore,
10+
fetchFromGitHub,
11+
fetchgit,
12+
fetchurl,
13+
...
14+
}:
15+
{
16+
"@playwright/test@1.58.2" = fetchurl {
17+
url = "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz";
18+
hash = "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==";
19+
};
20+
"fsevents@2.3.2" = fetchurl {
21+
url = "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz";
22+
hash = "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==";
23+
};
24+
"playwright-core@1.58.2" = fetchurl {
25+
url = "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz";
26+
hash = "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==";
27+
};
28+
"playwright@1.58.2" = fetchurl {
29+
url = "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz";
30+
hash = "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==";
31+
};
32+
}

pkgs/id/e2e/playwright.config.ts

Lines changed: 71 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import { defineConfig, devices } from "@playwright/test";
33
/**
44
* Playwright E2E test configuration for the id web UI.
55
*
6-
* Runs tests against both Chromium and Firefox.
6+
* Locally: runs tests against both Chromium and Firefox.
7+
* In nix sandbox: runs Firefox only (Chromium has shared-lib issues in the
8+
* sandboxed build environment).
9+
*
710
* In Nix environments, set PLAYWRIGHT_BROWSERS_PATH to the nix-provided
811
* playwright-driver.browsers output (done automatically in nix-common.nix).
912
*
1013
* Usage:
1114
* cd e2e && bun install && bunx playwright test
12-
* just test-e2e # runs against both browsers
15+
* just test-e2e # runs against both browsers (or firefox-only in nix)
1316
* just test-e2e-chromium # chromium only
1417
* just test-e2e-firefox # firefox only
1518
*/
@@ -20,6 +23,70 @@ import { defineConfig, devices } from "@playwright/test";
2023
const CHROMIUM_PORT = Number(process.env.TEST_PORT) || 4173;
2124
const FIREFOX_PORT = CHROMIUM_PORT + 1;
2225

26+
// In nix sandbox builds, disable P2P networking (no UDP sockets available).
27+
// E2E tests only exercise the web UI, not P2P features.
28+
const IS_NIX_BUILD = !!process.env.NIX_BUILD_TOP;
29+
const OFFLINE_FLAGS = IS_NIX_BUILD ? " --no-mdns --no-relay --no-gossip" : "";
30+
31+
// Chromium fails in nix sandbox due to missing shared libraries / /dev/shm.
32+
// Firefox works reliably, so we skip Chromium in sandboxed nix builds.
33+
const projects = IS_NIX_BUILD
34+
? [
35+
{
36+
name: "firefox",
37+
use: {
38+
...devices["Desktop Firefox"],
39+
baseURL: `http://localhost:${FIREFOX_PORT}`,
40+
},
41+
},
42+
]
43+
: [
44+
{
45+
name: "chromium",
46+
use: {
47+
...devices["Desktop Chrome"],
48+
baseURL: `http://localhost:${CHROMIUM_PORT}`,
49+
},
50+
},
51+
{
52+
name: "firefox",
53+
use: {
54+
...devices["Desktop Firefox"],
55+
baseURL: `http://localhost:${FIREFOX_PORT}`,
56+
},
57+
},
58+
];
59+
60+
const webServers = IS_NIX_BUILD
61+
? [
62+
{
63+
command: `../target/debug/id serve --web --port ${FIREFOX_PORT} --ephemeral${OFFLINE_FLAGS}`,
64+
port: FIREFOX_PORT,
65+
reuseExistingServer: false,
66+
timeout: 60_000,
67+
stdout: "pipe" as const,
68+
stderr: "pipe" as const,
69+
},
70+
]
71+
: [
72+
{
73+
command: `../target/debug/id serve --web --port ${CHROMIUM_PORT} --ephemeral${OFFLINE_FLAGS}`,
74+
port: CHROMIUM_PORT,
75+
reuseExistingServer: !process.env.CI,
76+
timeout: 60_000,
77+
stdout: "pipe" as const,
78+
stderr: "pipe" as const,
79+
},
80+
{
81+
command: `../target/debug/id serve --web --port ${FIREFOX_PORT} --ephemeral${OFFLINE_FLAGS}`,
82+
port: FIREFOX_PORT,
83+
reuseExistingServer: !process.env.CI,
84+
timeout: 60_000,
85+
stdout: "pipe" as const,
86+
stderr: "pipe" as const,
87+
},
88+
];
89+
2390
export default defineConfig({
2491
testDir: "./tests",
2592
fullyParallel: false, // tests within a project run sequentially
@@ -34,39 +101,6 @@ export default defineConfig({
34101
screenshot: "only-on-failure",
35102
},
36103

37-
projects: [
38-
{
39-
name: "chromium",
40-
use: {
41-
...devices["Desktop Chrome"],
42-
baseURL: `http://localhost:${CHROMIUM_PORT}`,
43-
},
44-
},
45-
{
46-
name: "firefox",
47-
use: {
48-
...devices["Desktop Firefox"],
49-
baseURL: `http://localhost:${FIREFOX_PORT}`,
50-
},
51-
},
52-
],
53-
54-
webServer: [
55-
{
56-
command: `../target/debug/id serve --web --port ${CHROMIUM_PORT} --ephemeral`,
57-
port: CHROMIUM_PORT,
58-
reuseExistingServer: !process.env.CI,
59-
timeout: 60_000,
60-
stdout: "pipe",
61-
stderr: "pipe",
62-
},
63-
{
64-
command: `../target/debug/id serve --web --port ${FIREFOX_PORT} --ephemeral`,
65-
port: FIREFOX_PORT,
66-
reuseExistingServer: !process.env.CI,
67-
timeout: 60_000,
68-
stdout: "pipe",
69-
stderr: "pipe",
70-
},
71-
],
104+
projects,
105+
webServer: webServers,
72106
});

pkgs/id/e2e/tests/websocket.spec.ts

Lines changed: 29 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -458,36 +458,36 @@ test.describe("Error Recovery", () => {
458458
await createFileWithUniqueContent(page, fileName, baseURL!);
459459
await waitForEditorReady(page);
460460

461-
// Close cleanly with code 1000 — should NOT trigger reconnect
462-
// Note: code 1000 onclose only sets connected=false, does NOT call updateStatus()
461+
// Disconnect cleanly via the collab API (sets intentionalClose flag + close(1000))
462+
// We use disconnect() instead of raw ws.close(1000) because the WebSocket close
463+
// handshake can fail/timeout, causing the browser to fire onclose with code 1006
464+
// instead of the requested 1000 — which would spuriously trigger reconnect.
463465
await page.evaluate(() => {
464-
const app = (window as unknown as { idApp: { collab: { ws: WebSocket } } }).idApp;
465-
if (app?.collab?.ws) {
466-
app.collab.ws.close(1000, "Clean close");
466+
const app = (window as unknown as { idApp: { collab: { disconnect: () => void } } }).idApp;
467+
if (app?.collab) {
468+
app.collab.disconnect();
467469
}
468470
});
469471

470-
// Wait for the WebSocket close handshake to complete (async in Firefox)
471-
await page.waitForFunction(
472-
() => {
473-
const app = (window as unknown as { idApp: { collab: { ws: WebSocket } } }).idApp;
474-
const ws = app?.collab?.ws;
475-
return !ws || ws.readyState === WebSocket.CLOSED;
476-
},
477-
{ timeout: 5_000 },
478-
);
472+
// After disconnect(), currentWs is immediately set to null (no async wait needed)
473+
await page.waitForTimeout(500);
479474

480-
// Wait 3s (longer than initial reconnect backoff of 1s) to verify no reconnect attempt
475+
// collab.ws getter returns null after disconnect() sets currentWs = null
476+
const wsIsNull = await page.evaluate(() => {
477+
const app = (window as unknown as { idApp: { collab: { ws: WebSocket | null } | null } }).idApp;
478+
return !app?.collab?.ws;
479+
});
480+
expect(wsIsNull).toBeTruthy();
481+
482+
// Wait longer than initial reconnect backoff (1s) to verify no reconnect attempt
481483
await page.waitForTimeout(3_000);
482484

483-
// Status should still show "connected" (code 1000 doesn't update status)
484-
// The key test is that NO reconnect happened — the WS stays closed
485-
const wsStillClosed = await page.evaluate(() => {
486-
const app = (window as unknown as { idApp: { collab: { ws: WebSocket } } }).idApp;
487-
const ws = app?.collab?.ws;
488-
return !ws || ws.readyState === WebSocket.CLOSED;
485+
// WS should still be null — no reconnect was scheduled
486+
const wsStillNull = await page.evaluate(() => {
487+
const app = (window as unknown as { idApp: { collab: { ws: WebSocket | null } | null } }).idApp;
488+
return !app?.collab?.ws;
489489
});
490-
expect(wsStillClosed).toBeTruthy();
490+
expect(wsStillNull).toBeTruthy();
491491
});
492492
});
493493

@@ -496,12 +496,8 @@ test.describe("Error Recovery", () => {
496496
// ---------------------------------------------------------------------------
497497

498498
test.describe("Multi-User Collab", () => {
499-
// Helper to get the base URL for collab tests (can't use page fixture's baseURL)
500-
const getBaseURL = () => `http://localhost:${process.env.TEST_PORT || 4173}`;
501-
502499
/** Set up two pages with the same file open in both editors */
503-
async function setupCollabPair(browser: import("@playwright/test").Browser) {
504-
const baseURL = getBaseURL();
500+
async function setupCollabPair(browser: import("@playwright/test").Browser, baseURL: string) {
505501
const fileName = `collab-${Date.now()}.txt`;
506502
const context1 = await browser.newContext({ baseURL });
507503
const context2 = await browser.newContext({ baseURL });
@@ -526,8 +522,8 @@ test.describe("Multi-User Collab", () => {
526522
return { context1, context2, page1, page2, fileName };
527523
}
528524

529-
test("two tabs can open the same file simultaneously", async ({ browser }) => {
530-
const { context1, context2, page1, page2 } = await setupCollabPair(browser);
525+
test("two tabs can open the same file simultaneously", async ({ browser, baseURL }) => {
526+
const { context1, context2, page1, page2 } = await setupCollabPair(browser, baseURL!);
531527

532528
try {
533529
// Both editors should be connected
@@ -539,8 +535,8 @@ test.describe("Multi-User Collab", () => {
539535
}
540536
});
541537

542-
test("edits from one user appear in other user's editor", async ({ browser }) => {
543-
const { context1, context2, page1, page2 } = await setupCollabPair(browser);
538+
test("edits from one user appear in other user's editor", async ({ browser, baseURL }) => {
539+
const { context1, context2, page1, page2 } = await setupCollabPair(browser, baseURL!);
544540

545541
try {
546542
// User 1 types something (with delay to allow collab sync per character)
@@ -557,8 +553,8 @@ test.describe("Multi-User Collab", () => {
557553
}
558554
});
559555

560-
test("bidirectional editing works", async ({ browser }) => {
561-
const { context1, context2, page1, page2 } = await setupCollabPair(browser);
556+
test("bidirectional editing works", async ({ browser, baseURL }) => {
557+
const { context1, context2, page1, page2 } = await setupCollabPair(browser, baseURL!);
562558

563559
try {
564560
// User 1 types first (slow enough for collab to sync each step)

pkgs/id/flake.nix

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@
7575
bunDeps = bun2nixPkg.fetchBunDeps {
7676
bunNix = ./web/bun.nix;
7777
};
78+
e2eBunDeps = bun2nixPkg.fetchBunDeps {
79+
bunNix = ./e2e/bun.nix;
80+
};
7881

7982
# Helper to create a check that runs a just command
8083
mkCheck =
@@ -242,12 +245,89 @@
242245
test-web-typecheck = mkCheck "test-web-typecheck" "test-web-typecheck";
243246
doc = mkCheck "doc" "doc";
244247
cargo-check = mkCheck "cargo-check" "cargo-check";
248+
249+
# Playwright E2E tests (requires building the binary + browser binaries)
250+
test-e2e = pkgs.stdenv.mkDerivation {
251+
name = "id-test-e2e";
252+
src = ./.;
253+
inherit buildInputs;
254+
nativeBuildInputs = nativeBuildInputs ++ [
255+
bun2nixPkg.hook
256+
# TODO: Switch back to `bunx playwright test` once Bun supports Playwright's
257+
# ESM config loader (.esm.preflight virtual imports). Bun's runtime doesn't handle
258+
# the Node.js-specific ESM hooks that Playwright uses for TypeScript config loading.
259+
# Tracking: https://github.com/oven-sh/bun/pull/28610
260+
pkgs.nodejs
261+
];
262+
inherit (opensslEnv) OPENSSL_DIR;
263+
inherit (opensslEnv) OPENSSL_LIB_DIR;
264+
inherit (opensslEnv) OPENSSL_INCLUDE_DIR;
265+
inherit (opensslEnv) PKG_CONFIG_PATH;
266+
267+
# bun2nix hook: install web deps offline via pre-fetched cache
268+
inherit bunDeps;
269+
bunRoot = "web";
270+
bunInstallFlags = [ "--linker=hoisted" ];
271+
dontUseBunBuild = true;
272+
dontUseBunCheck = true;
273+
dontUseBunInstall = true;
274+
275+
buildPhase = ''
276+
export HOME=$(mktemp -d)
277+
export CARGO_HOME=$HOME/.cargo
278+
279+
# @tailwindcss/cli uses @parcel/watcher (native module) which needs libstdc++
280+
export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
281+
282+
# Configure cargo to use vendored dependencies (nix sandbox has no network)
283+
cat >> .cargo/config.toml << EOF
284+
285+
[source.crates-io]
286+
replace-with = "vendored-sources"
287+
288+
[source."git+https://github.com/developing-today-forks/distributed-topic-tracker?branch=main"]
289+
git = "https://github.com/developing-today-forks/distributed-topic-tracker"
290+
branch = "main"
291+
replace-with = "vendored-sources"
292+
293+
[source.vendored-sources]
294+
directory = "${cargoDeps}"
295+
EOF
296+
297+
# Build web assets (bun2nix hook already installed node_modules via bunNodeModulesInstallPhase)
298+
(cd web && bun run build)
299+
300+
# Build the binary with web feature
301+
cargo build --features web
302+
303+
# Install e2e deps from pre-fetched cache (separate from web deps)
304+
E2E_CACHE_DIR=$(mktemp -d)
305+
cp -r "${e2eBunDeps}"/share/bun-cache/. "$E2E_CACHE_DIR"
306+
(cd e2e && BUN_INSTALL_CACHE_DIR="$E2E_CACHE_DIR" \
307+
bun install --frozen-lockfile --linker=hoisted)
308+
309+
# Configure Playwright to use nix-provided browsers
310+
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
311+
export PLAYWRIGHT_BROWSERS_PATH="${pkgs.playwright-driver.browsers}"
312+
313+
# Run Playwright E2E tests
314+
# TODO: Switch to `bunx playwright test` once Bun's ESM loader supports
315+
# Playwright's .esm.preflight virtual imports for TypeScript config loading.
316+
# Tracking: https://github.com/oven-sh/bun/pull/28610
317+
(cd e2e && node node_modules/@playwright/test/cli.js test)
318+
'';
319+
installPhase = ''
320+
mkdir -p $out
321+
echo "test-e2e passed at $(date)" > $out/result.txt
322+
'';
323+
};
324+
245325
nix-fmt-check = pkgs.stdenv.mkDerivation {
246326
name = "id-nix-fmt-check";
247327
src = ./.;
248328
nativeBuildInputs = [ pkgs.nixfmt ];
249329
buildPhase = ''
250-
find . -name '*.nix' -not -path './web/bun.nix' | xargs nixfmt --check
330+
find . -name '*.nix' -not -path './web/bun.nix' -not -path './e2e/bun.nix' | xargs nixfmt --check
251331
'';
252332
installPhase = ''
253333
mkdir -p $out

pkgs/id/justfile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,18 @@ web-lint-fix:
168168

169169
# Regenerate all lockfiles (bun2nix + just-recipes.json)
170170
[group('deps')]
171-
lockfiles: bun2nix just-recipes
171+
lockfiles: bun2nix bun2nix-e2e just-recipes
172172

173173
# Regenerate web/bun.nix from web/bun.lock for offline nix builds
174174
[group('deps')]
175175
bun2nix:
176176
bun2nix --lock-file web/bun.lock --output-file web/bun.nix
177177

178+
# Regenerate e2e/bun.nix from e2e/bun.lock for offline nix builds
179+
[group('deps')]
180+
bun2nix-e2e:
181+
bun2nix --lock-file e2e/bun.lock --output-file e2e/bun.nix
182+
178183
# Regenerate just-recipes.json from justfile for dynamic nix app generation
179184
[group('deps')]
180185
just-recipes:

0 commit comments

Comments
 (0)