Skip to content

Commit b707e92

Browse files
committed
test(e2e): split persistence-vs-renderer for restore-on-relaunch flake
The restore-on-relaunch test has been failing on CI for weeks. Bumping the visibility timeout (30s → 60s → 90s) only kicks the can — the test ran past 90s on the latest CI and timed out the same way. Two things this commit does to make the failure actionable next time: 1. Check that h1 actually persisted `workspaces.json` to disk BEFORE launching h2. If the file isn't there, fail with the userDataDir listing — that's a clear "persistence broke" signal. If the file is there but the renderer in h2 doesn't see it, that points at the renderer-hydrate path instead. The two failure modes have been indistinguishable from the same "button not visible" error to date. 2. Replace the cold `toBeVisible({ timeout: 90s })` with a waitForFunction polling the e2e seam for `store.workspaces.length > 0`. The button is downstream of the store, so polling the upstream signal lets the downstream assertion run at the normal 15s timeout. waitForFunction also throws with a more specific timeout error if hydrate genuinely never fires. Wrapped the downstream assertions in a try/catch that logs the renderer's storeState, body innerText, and open dialogs on failure. The diagnostic dump lets us tell from the CI log whether (a) hydrate fired but the rail didn't render, (b) hydrate fired and the rail rendered but tabs are missing, or (c) hydrate never fired at all. Until now the failure just said "element not found" with no DOM context. Per-test timeout bumped to 240s — two cold starts back-to-back can drift past the 120s suite default under xvfb contention.
1 parent 8331d89 commit b707e92

1 file changed

Lines changed: 69 additions & 10 deletions

File tree

packages/desktop/tests/e2e/restore-on-relaunch.spec.ts

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { test, expect } from '@playwright/test';
2-
import { rmSync } from 'node:fs';
2+
import { rmSync, readdirSync, readFileSync, existsSync } from 'node:fs';
3+
import { join } from 'node:path';
34
import { launchApp } from './fixtures/launch.js';
45

6+
// Extra runway on top of playwright.config.ts's 120s default — this test
7+
// does two full cold-starts back-to-back (h1 + h2). Under xvfb suite
8+
// contention either can drift past 60s.
9+
test.setTimeout(240_000);
10+
511
test('relaunching restores workspaces, the active workspace, and open tabs', async () => {
612
// First launch: add workspace + open a pad
713
const h1 = await launchApp();
@@ -19,18 +25,71 @@ test('relaunching restores workspaces, the active workspace, and open tabs', asy
1925
// Close the app without wiping userData (don't call h1.close() which runs cleanup)
2026
await h1.app.close();
2127

28+
// Sanity-check that h1 actually persisted what we asked it to BEFORE
29+
// launching h2. Splits "persistence broke" from "renderer didn't
30+
// hydrate" cleanly when the test flakes — both have surfaced as the
31+
// same "button not visible" failure in past runs.
32+
const findStoreFile = (basename: string): string | null => {
33+
const candidates = [
34+
join(userDataDir, basename),
35+
join(userDataDir, 'etherpad-desktop', basename),
36+
join(userDataDir, 'Default', basename),
37+
];
38+
for (const p of candidates) {
39+
if (existsSync(p)) return p;
40+
}
41+
return null;
42+
};
43+
const wsFile = findStoreFile('workspaces.json');
44+
if (!wsFile) {
45+
// eslint-disable-next-line no-console
46+
console.error('[restore-on-relaunch] userDataDir contents:', readdirSync(userDataDir));
47+
throw new Error(`workspaces.json not found under ${userDataDir} — h1 didn't persist`);
48+
}
49+
const persistedWorkspaces = JSON.parse(readFileSync(wsFile, 'utf8'));
50+
expect(
51+
(persistedWorkspaces.workspaces as Array<{ name: string }>).map((w) => w.name),
52+
).toContain('Sticky');
53+
2254
// Second launch with the same userDataDir
2355
const h2 = await launchApp({ userDataDir });
2456
try {
25-
// Workspace should be restored from disk. Cold-start on a slow CI
26-
// runner (xvfb + Electron + initial getInitial round-trip + render)
27-
// can exceed 30s under contention — this test has flaked on ~50%
28-
// of PR CI runs for weeks at 30s, with each rerun succeeding within
29-
// the same timeout. 60s eats the flake without slowing the happy
30-
// path (passing runs complete in 8–12s).
31-
await expect(h2.shell.getByRole('button', { name: /open instance sticky/i }))
32-
.toBeVisible({ timeout: 90_000 });
33-
await expect(h2.shell.getByRole('tab', { name: /survives-restart/ })).toBeVisible({ timeout: 90_000 });
57+
// Polling the store via the e2e seam is the canonical "did hydrate
58+
// finish?" check — cheaper than `toBeVisible` polling DOM, and
59+
// immune to render-throttling under CI xvfb contention. The
60+
// workspace button is downstream of `store.workspaces.length > 0`,
61+
// so waiting for the underlying state to land first lets the next
62+
// assertion run at a normal 15s timeout instead of needing a long
63+
// overall ceiling. This test has flaked at 30s and 60s timeouts
64+
// for weeks; switching to a store-state wait is the structural fix.
65+
await h2.shell.waitForFunction(
66+
() => {
67+
const store = (globalThis as { __test_useShellStore?: { getState: () => { workspaces: unknown[] } } }).__test_useShellStore;
68+
return Boolean(store && store.getState().workspaces.length > 0);
69+
},
70+
undefined,
71+
{ timeout: 90_000 },
72+
);
73+
74+
// Dump diagnostic state if the button assertion fails — the trace
75+
// is otherwise opaque (we just know "element not found"). Wrap the
76+
// expect so we can log the store contents alongside the throw.
77+
try {
78+
await expect(h2.shell.getByRole('button', { name: /open instance sticky/i })).toBeVisible();
79+
await expect(h2.shell.getByRole('tab', { name: /survives-restart/ })).toBeVisible();
80+
} catch (err) {
81+
const diag = await h2.shell.evaluate(() => {
82+
const store = (globalThis as { __test_useShellStore?: { getState: () => unknown } }).__test_useShellStore;
83+
return {
84+
storeState: store ? store.getState() : null,
85+
bodyText: (document.body?.innerText ?? '').slice(0, 2000),
86+
openDialogs: Array.from(document.querySelectorAll('[role="dialog"]')).map((d) => d.getAttribute('aria-labelledby')),
87+
};
88+
}).catch(() => null);
89+
// eslint-disable-next-line no-console
90+
console.error('[restore-on-relaunch] failed; renderer state:', JSON.stringify(diag, null, 2));
91+
throw err;
92+
}
3493
} finally {
3594
await h2.app.close();
3695
rmSync(userDataDir, { recursive: true, force: true });

0 commit comments

Comments
 (0)