mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-21 02:26:31 +03:00
fix: stop cross-project live session leakage and stale-adapter 401s
Field session on a nested SvelteKit app surfaced a self-reinforcing leak: localStorage is per-origin, two projects reused 127.0.0.1:5174, and a React project's leftover cycling session was resumed inside the Svelte project. Its checkpoints then materialized a ghost session in the new project's durable store that kept reattaching after every discard, and a stale adapter module 401'd on live.js, hiding the picker. Four fixes: - Server: only session-creating events (generate, steer) may mint a journal. Progress events (checkpoints, mount acks, accept/discard) for unknown ids are refused with 404 unknown_session and never enqueued, so foreign browser state cannot create ghost sessions. Browser sends are gated so progress never overtakes its own creating POST (the Go-time checkpoint and generate are concurrent fetches; the first sweep caught the out-of-order arrival breaking every SvelteKit flow). Steer checkpoints now follow the steer event for the same reason. - Browser: saved sessions carry the server's appRoot; a session stamped by another project is dropped at load time. Unstamped legacy state is caught by the unknown_session refusal, which clears local state and re-arms the picker with an explanatory toast. - SvelteKit adapter: the layout import carries a token-derived revision query so a helper restart changes the module specifier and no Vite client/SSR cache can serve an adapter with a rotated-out token; live-inject --port reads the running helper's token from server.json instead of writing an unauthenticated live.js URL; script load failures log an actionable console error; and adapter removal is byte-exact (the old regex swallowed the next line's indentation). - live.mjs resolves surface briefs from appRoot, then contextRoot, then repoRoot, matching context.mjs in nested-app repos. Tests: server unknown-session rejection units, adapter revision/ byte-exact-removal units, and a foreign-session e2e scenario that seeds another project's localStorage state and asserts it is cleared, no ghost journal materializes, and picking still works. AI-assisted (Claude Code). Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Code
parent
69456364b2
commit
a83d767cf9
@@ -1124,6 +1124,82 @@ for (const { name, fixture } of fixtures) {
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRunScenario('foreign') && fixture.runtime.foreignSessionScenario) {
|
||||
it('clears another project\'s leftover browser session instead of resuming it', liveE2eTestOptions, async (t) => {
|
||||
if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) {
|
||||
t.skip('manual scenario filter is active');
|
||||
return;
|
||||
}
|
||||
// Repro of the cross-project leak: localStorage is per-ORIGIN, and two
|
||||
// projects routinely reuse the same localhost port across time. A
|
||||
// leftover cycling session from the other project used to be resumed
|
||||
// ("Variants ready" with no user action), and its checkpoints
|
||||
// materialized a ghost session in THIS project's durable store that
|
||||
// kept reattaching after every discard.
|
||||
const agent = createFakeAgent();
|
||||
const session = await bootFixtureSession({
|
||||
name,
|
||||
fixture,
|
||||
browser,
|
||||
agent,
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
const { page, appRoot, teardown } = session;
|
||||
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
|
||||
try {
|
||||
await waitForHandshake(page);
|
||||
|
||||
const cases = [
|
||||
// Stamped with another app's root: dropped at load time, before
|
||||
// any server roundtrip.
|
||||
{ id: 'f0e1d2c3', appRoot: '/somewhere/else/entirely' },
|
||||
// Legacy shape without a stamp: dropped when the server refuses
|
||||
// its first checkpoint as unknown_session.
|
||||
{ id: 'deadf00d' },
|
||||
];
|
||||
for (const foreign of cases) {
|
||||
t.diagnostic(`Seeding foreign session ${foreign.id}${foreign.appRoot ? ' (stamped)' : ' (legacy shape)'}`);
|
||||
await page.evaluate((saved) => {
|
||||
localStorage.setItem('impeccable-live-session', JSON.stringify(saved));
|
||||
localStorage.removeItem('impeccable-live-session-handled');
|
||||
}, {
|
||||
id: foreign.id,
|
||||
state: 'CYCLING',
|
||||
expected: 3,
|
||||
arrived: 3,
|
||||
visible: 1,
|
||||
sourceFile: 'src/App.jsx',
|
||||
pageUrl: '/',
|
||||
checkpointRevision: 5,
|
||||
...(foreign.appRoot ? { appRoot: foreign.appRoot } : {}),
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitForHandshake(page);
|
||||
|
||||
const deadline = Date.now() + 20_000;
|
||||
for (;;) {
|
||||
const current = await readLiveSessionStorage(page);
|
||||
if (!current || current.id !== foreign.id) break;
|
||||
if (Date.now() > deadline) throw new Error(`foreign session ${foreign.id} was never cleared`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
const sessionsDir = join(appRoot, '.impeccable/live/sessions');
|
||||
assert.equal(
|
||||
existsSync(join(sessionsDir, `${foreign.id}.jsonl`)),
|
||||
false,
|
||||
`no ghost journal for ${foreign.id} may materialize in this project's store`,
|
||||
);
|
||||
}
|
||||
|
||||
// The surface is genuinely back: a fresh pick must work.
|
||||
await pickElement(page, pickSelector, { position: fixture.runtime.pickPosition });
|
||||
} finally {
|
||||
await teardownAndResetBrowser(teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRunScenario('manual') && Array.isArray(fixture.runtime.manualEditScenarios) && fixture.runtime.manualEditScenarios.length > 0) {
|
||||
const manualScenarioFilter = process.env.IMPECCABLE_E2E_MANUAL_SCENARIO || '';
|
||||
for (const scenario of fixture.runtime.manualEditScenarios) {
|
||||
|
||||
Reference in New Issue
Block a user