mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-23 05:10:50 +02:00
BROWSE_PERSIST_STATE=1 snapshots cookies + per-tab URL/localStorage/ sessionStorage to <stateDir>/session-state.json (0600) on a 30s unref'd interval and at clean shutdown, and restores on the next launch — killing the top-complained auth-lost-on-restart class (#778, #2193, #1128, #1129). Security invariants mirror state save|load: loadedHtml and owner are never persisted and never accepted from disk; restored cookies pass the same hygiene filter (localhost/.internal/metadata domains dropped); restoreState re-validates every URL. Default OFF; headed mode excluded (the persistent profile owns that state). Hardened past the fork's shape per review R3: corrupt state quarantines to .corrupt (forensic artifact, boots fresh, one log line), snapshot failures warn once and never kill the daemon, and the boot log reports restored counts or fresh-session status. Module + 10 tests ported (MIT header retained); server wiring at launch, interval, and shutdown; skill docs section added (regen rides the cluster regen commit). Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Sina Matian
Claude Fable 5
parent
fa4e4c3f2c
commit
809434013e
@@ -216,6 +216,28 @@ should route through `browse` — `screenshot --selector` for visual output,
|
|||||||
`npm i puppeteer` and downloading a second Chromium that drifts out of version sync.
|
`npm i puppeteer` and downloading a second Chromium that drifts out of version sync.
|
||||||
One install to pin, one daemon's lifecycle to manage.
|
One install to pin, one daemon's lifecycle to manage.
|
||||||
|
|
||||||
|
## Session Persistence (opt-in)
|
||||||
|
|
||||||
|
By default the headless daemon's cookies and tab state die with it — a crash,
|
||||||
|
version auto-restart, or `browse stop` logs you out of everything (#778).
|
||||||
|
Opt in to persistence with `BROWSE_PERSIST_STATE=1` in the daemon's
|
||||||
|
environment: the daemon then snapshots cookies + per-tab
|
||||||
|
URL/localStorage/sessionStorage to `<stateDir>/session-state.json` (0600)
|
||||||
|
every 30 seconds and at clean shutdown, and restores it on the next launch.
|
||||||
|
|
||||||
|
Facts that matter:
|
||||||
|
- **Default OFF.** Cookies on disk are a real cost; the user opts in.
|
||||||
|
- **Headless only.** Headed mode's persistent Chromium profile already owns
|
||||||
|
its state; replaying tabs would clobber the user's window.
|
||||||
|
- **Never persisted:** loaded HTML and tab ownership — a tampered state file
|
||||||
|
cannot smuggle content past load-html's checks or forge ownership. Cookies
|
||||||
|
for localhost, `.internal`, and cloud-metadata addresses are dropped on
|
||||||
|
restore.
|
||||||
|
- **Corrupt state** is moved to `session-state.json.corrupt` (kept for
|
||||||
|
diagnosis) and the daemon boots fresh — persistence can never block a
|
||||||
|
launch. The boot log says which happened: `Session state restored: N
|
||||||
|
cookies / M tabs` or `fresh session`.
|
||||||
|
|
||||||
## User Handoff
|
## User Handoff
|
||||||
|
|
||||||
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
|
When you hit something you can't handle in headless mode (CAPTCHA, complex auth, multi-factor
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ import {
|
|||||||
} from './token-registry';
|
} from './token-registry';
|
||||||
import { validateTempPath } from './path-security';
|
import { validateTempPath } from './path-security';
|
||||||
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config';
|
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks, isPairAgentEnabled } from './config';
|
||||||
|
import {
|
||||||
|
isSessionPersistEnabled, persistSessionState, restoreSessionState,
|
||||||
|
sessionPersistIntervalMs, SESSION_STATE_FILE,
|
||||||
|
} from './session-persist';
|
||||||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||||||
import { createSseEndpoint } from './sse-helpers';
|
import { createSseEndpoint } from './sse-helpers';
|
||||||
import { initAuditLog, writeAuditEntry } from './audit';
|
import { initAuditLog, writeAuditEntry } from './audit';
|
||||||
@@ -1654,6 +1658,16 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||||||
if (agentWatchdogInterval) clearInterval(agentWatchdogInterval);
|
if (agentWatchdogInterval) clearInterval(agentWatchdogInterval);
|
||||||
await flushBuffers();
|
await flushBuffers();
|
||||||
|
|
||||||
|
// Final session snapshot before the browser goes away (#778). Best
|
||||||
|
// effort: shutdown must never hang on a wedged page.evaluate.
|
||||||
|
if (isSessionPersistEnabled()) {
|
||||||
|
try {
|
||||||
|
await persistSessionState(cfgBrowserManager, path.join(config.stateDir, SESSION_STATE_FILE));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn(`[browse] SESSION_PERSIST_FAILED at shutdown: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await cfgBrowserManager.close();
|
await cfgBrowserManager.close();
|
||||||
|
|
||||||
cleanSingletonLocks(resolveChromiumProfile());
|
cleanSingletonLocks(resolveChromiumProfile());
|
||||||
@@ -3078,6 +3092,36 @@ export async function start() {
|
|||||||
} else {
|
} else {
|
||||||
await browserManager.launch();
|
await browserManager.launch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Opt-in session persistence (#778 class) ─────────────────
|
||||||
|
// BROWSE_PERSIST_STATE=1: restore cookies/storage/tabs from the last
|
||||||
|
// snapshot, then keep snapshotting on an interval. Launched mode only —
|
||||||
|
// the headed persistent profile owns its own state. The final snapshot
|
||||||
|
// at clean shutdown lives in buildFetchHandler's shutdown().
|
||||||
|
if (isSessionPersistEnabled() && browserManager.getConnectionMode() === 'launched') {
|
||||||
|
const sessionStatePath = path.join(config.stateDir, SESSION_STATE_FILE);
|
||||||
|
try {
|
||||||
|
if (await restoreSessionState(browserManager, sessionStatePath)) {
|
||||||
|
const st = await browserManager.saveState();
|
||||||
|
console.log(`[browse] Session state restored: ${st.cookies.length} cookies / ${st.pages.length} tabs (BROWSE_PERSIST_STATE=1)`);
|
||||||
|
} else {
|
||||||
|
console.log('[browse] Session persistence on; no prior state — fresh session (BROWSE_PERSIST_STATE=1)');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn(`[browse] SESSION_RESTORE_FAILED: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
let persistWarned = false;
|
||||||
|
setInterval(() => {
|
||||||
|
persistSessionState(browserManager, sessionStatePath).catch((err: any) => {
|
||||||
|
// Warn once — a full disk must not spam the log every 30s, and a
|
||||||
|
// snapshot failure must never kill the daemon (R3).
|
||||||
|
if (!persistWarned) {
|
||||||
|
persistWarned = true;
|
||||||
|
console.warn(`[browse] SESSION_PERSIST_FAILED: ${err?.message ?? err} (further failures suppressed)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, sessionPersistIntervalMs()).unref();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Opt-in session-state persistence (#778, #2193, #1128, #1129).
|
||||||
|
*
|
||||||
|
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
|
||||||
|
*
|
||||||
|
* With BROWSE_PERSIST_STATE=1, the headless daemon snapshots cookies +
|
||||||
|
* per-tab URL/localStorage/sessionStorage to <stateDir>/session-state.json
|
||||||
|
* on an interval and at clean shutdown, and restores it on the next launch.
|
||||||
|
* Kills the auth-lost-on-restart class: a crash or binary-version
|
||||||
|
* auto-restart no longer silently logs the user out of everything.
|
||||||
|
*
|
||||||
|
* Default OFF: cookies on disk (0600) are a real cost the user must opt
|
||||||
|
* into. Headed mode is excluded — the persistent Chromium profile already
|
||||||
|
* owns that state, and replaying tabs would clobber the user's window.
|
||||||
|
*
|
||||||
|
* Disk shape (version 1): { version, savedAt, cookies, pages[{url,
|
||||||
|
* isActive, storage}] }. loadedHtml and owner are NEVER persisted — same
|
||||||
|
* in-memory-only invariant as `state save|load` (meta-commands.ts): a
|
||||||
|
* tampered file must not smuggle HTML past load-html's checks or forge tab
|
||||||
|
* ownership.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import type { BrowserManager, BrowserState } from './browser-manager';
|
||||||
|
import { writeSecureFile } from './file-permissions';
|
||||||
|
import { safeUnlinkQuiet } from './error-handling';
|
||||||
|
|
||||||
|
/** Rename a corrupt state file to .corrupt (forensic artifact) — best effort. */
|
||||||
|
function quarantineCorrupt(filePath: string): void {
|
||||||
|
try {
|
||||||
|
fs.renameSync(filePath, `${filePath}.corrupt`);
|
||||||
|
} catch {
|
||||||
|
safeUnlinkQuiet(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SESSION_STATE_FILE = 'session-state.json';
|
||||||
|
export const SESSION_STATE_VERSION = 1;
|
||||||
|
|
||||||
|
/** Config gate. Documented in browse/SKILL.md ("Session persistence"). */
|
||||||
|
export function isSessionPersistEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||||
|
return env.BROWSE_PERSIST_STATE === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist interval (ms). Env override exists for tests. */
|
||||||
|
export function sessionPersistIntervalMs(env: NodeJS.ProcessEnv = process.env): number {
|
||||||
|
const parsed = parseInt(env.BROWSE_PERSIST_INTERVAL_MS || '', 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a BrowserState to the on-disk v1 shape. Strips loadedHtml,
|
||||||
|
* loadedHtmlWaitUntil, and owner (in-memory-only invariants).
|
||||||
|
*/
|
||||||
|
export function serializeSessionState(state: BrowserState): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
version: SESSION_STATE_VERSION,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
cookies: state.cookies,
|
||||||
|
pages: state.pages.map((p) => ({
|
||||||
|
url: p.url,
|
||||||
|
isActive: p.isActive,
|
||||||
|
storage: p.storage,
|
||||||
|
})),
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same cookie hygiene as `state load` (meta-commands.ts, kept in sync by
|
||||||
|
* comment there): drop malformed cookies and internal-network domains a
|
||||||
|
* tampered file could use to reach localhost services or cloud metadata.
|
||||||
|
*/
|
||||||
|
export function filterSessionCookies(cookies: unknown[]): BrowserState['cookies'] {
|
||||||
|
return cookies.filter((c: any) => {
|
||||||
|
if (typeof c !== 'object' || !c) return false;
|
||||||
|
if (typeof c.name !== 'string' || typeof c.value !== 'string') return false;
|
||||||
|
if (typeof c.domain !== 'string' || !c.domain) return false;
|
||||||
|
const d = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
|
||||||
|
if (d === 'localhost' || d.endsWith('.internal') || d === '169.254.169.254') return false;
|
||||||
|
return true;
|
||||||
|
}) as BrowserState['cookies'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse + validate the on-disk shape into a BrowserState. Returns null for
|
||||||
|
* anything malformed (corrupt JSON, wrong version, missing arrays).
|
||||||
|
* loadedHtml/owner are stripped unconditionally even if present on disk.
|
||||||
|
*/
|
||||||
|
export function deserializeSessionState(raw: string): BrowserState | null {
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!data || data.version !== SESSION_STATE_VERSION) return null;
|
||||||
|
if (!Array.isArray(data.cookies) || !Array.isArray(data.pages)) return null;
|
||||||
|
return {
|
||||||
|
cookies: filterSessionCookies(data.cookies),
|
||||||
|
pages: data.pages.map((p: any) => ({
|
||||||
|
url: typeof p?.url === 'string' ? p.url : '',
|
||||||
|
isActive: Boolean(p?.isActive),
|
||||||
|
storage: p?.storage && typeof p.storage === 'object'
|
||||||
|
? {
|
||||||
|
localStorage: typeof p.storage.localStorage === 'object' && p.storage.localStorage ? p.storage.localStorage : {},
|
||||||
|
sessionStorage: typeof p.storage.sessionStorage === 'object' && p.storage.sessionStorage ? p.storage.sessionStorage : {},
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
// NEVER accept loadedHtml / loadedHtmlWaitUntil / owner from disk.
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot the live session to disk (0600). No-op outside launched
|
||||||
|
* (headless) mode — the headed persistent profile owns its own state.
|
||||||
|
*/
|
||||||
|
export async function persistSessionState(bm: BrowserManager, filePath: string): Promise<void> {
|
||||||
|
if (bm.getConnectionMode() !== 'launched') return;
|
||||||
|
const state = await bm.saveState();
|
||||||
|
writeSecureFile(filePath, serializeSessionState(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a persisted session into a freshly launched manager. Returns true
|
||||||
|
* when state was restored, false when there was nothing (or corrupt data —
|
||||||
|
* which is warned, deleted, and skipped rather than blocking launch).
|
||||||
|
* restoreState re-validates every URL before navigating.
|
||||||
|
*/
|
||||||
|
export async function restoreSessionState(bm: BrowserManager, filePath: string): Promise<boolean> {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'ENOENT') return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const state = deserializeSessionState(raw);
|
||||||
|
if (!state) {
|
||||||
|
// Boot fresh, keep the evidence: the corrupt file moves to .corrupt so a
|
||||||
|
// 3-week-later bug report is reconstructable from the artifact.
|
||||||
|
console.warn(`[browse] SESSION_STATE_INVALID: corrupt ${filePath} moved to .corrupt; starting fresh`);
|
||||||
|
quarantineCorrupt(filePath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// launch() opens one blank tab; replace it rather than restoring alongside.
|
||||||
|
await bm.closeAllPages();
|
||||||
|
await bm.restoreState(state);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Opt-in session-state persistence (#778, #2193, #1128, #1129).
|
||||||
|
*
|
||||||
|
* Pins the leg of the browser-lifecycle contract that had no coverage:
|
||||||
|
* "shut down without losing live session state." Pre-fix, the headless
|
||||||
|
* daemon used non-persistent chromium.launch() with zero storage
|
||||||
|
* persistence — any crash or binary-version auto-restart silently lost all
|
||||||
|
* auth. These tests fail on the old tree (module absent, no wiring).
|
||||||
|
*
|
||||||
|
* Suites:
|
||||||
|
* 1. Pure serialize/deserialize/filter units (free, instant).
|
||||||
|
* 2. Real-Chromium round-trip: cookie + localStorage survive a full
|
||||||
|
* manager teardown + relaunch via persist/restore.
|
||||||
|
* 3. Static wiring tripwire: server.ts restores at launch, snapshots at
|
||||||
|
* shutdown, and the gate is BROWSE_PERSIST_STATE (default off).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, afterAll } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
serializeSessionState, deserializeSessionState, filterSessionCookies,
|
||||||
|
isSessionPersistEnabled, persistSessionState, restoreSessionState,
|
||||||
|
} from '../src/session-persist';
|
||||||
|
import type { BrowserState } from '../src/browser-manager';
|
||||||
|
|
||||||
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-persist-'));
|
||||||
|
afterAll(() => { fs.rmSync(tmpRoot, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
describe('session-persist units', () => {
|
||||||
|
test('config gate: default off, exactly "1" enables', () => {
|
||||||
|
expect(isSessionPersistEnabled({} as NodeJS.ProcessEnv)).toBe(false);
|
||||||
|
expect(isSessionPersistEnabled({ BROWSE_PERSIST_STATE: '0' } as any)).toBe(false);
|
||||||
|
expect(isSessionPersistEnabled({ BROWSE_PERSIST_STATE: 'true' } as any)).toBe(false);
|
||||||
|
expect(isSessionPersistEnabled({ BROWSE_PERSIST_STATE: '1' } as any)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serialize strips loadedHtml/owner, keeps cookies + storage', () => {
|
||||||
|
const state: BrowserState = {
|
||||||
|
cookies: [{ name: 'sid', value: 'abc', domain: 'example.com', path: '/', expires: -1, httpOnly: false, secure: false, sameSite: 'Lax' } as any],
|
||||||
|
pages: [{
|
||||||
|
url: 'https://example.com/app',
|
||||||
|
isActive: true,
|
||||||
|
storage: { localStorage: { k: 'v' }, sessionStorage: {} },
|
||||||
|
loadedHtml: '<script>evil</script>',
|
||||||
|
loadedHtmlWaitUntil: 'load',
|
||||||
|
owner: 'agent-1',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const raw = serializeSessionState(state);
|
||||||
|
expect(raw).not.toContain('loadedHtml');
|
||||||
|
expect(raw).not.toContain('evil');
|
||||||
|
expect(raw).not.toContain('owner');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
expect(parsed.version).toBe(1);
|
||||||
|
expect(parsed.cookies[0].name).toBe('sid');
|
||||||
|
expect(parsed.pages[0].storage.localStorage.k).toBe('v');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deserialize rejects corrupt JSON, wrong version, missing arrays', () => {
|
||||||
|
expect(deserializeSessionState('not json{')).toBeNull();
|
||||||
|
expect(deserializeSessionState('{"version":99,"cookies":[],"pages":[]}')).toBeNull();
|
||||||
|
expect(deserializeSessionState('{"version":1,"cookies":{}}')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deserialize strips loadedHtml/owner even if tampered onto disk', () => {
|
||||||
|
const raw = JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
cookies: [],
|
||||||
|
pages: [{ url: 'https://x.com', isActive: true, storage: null, loadedHtml: '<h1>x</h1>', owner: 'evil' }],
|
||||||
|
});
|
||||||
|
const state = deserializeSessionState(raw)!;
|
||||||
|
expect((state.pages[0] as any).loadedHtml).toBeUndefined();
|
||||||
|
expect((state.pages[0] as any).owner).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cookie filter drops malformed + internal-network domains', () => {
|
||||||
|
const kept = filterSessionCookies([
|
||||||
|
{ name: 'ok', value: 'v', domain: 'example.com' },
|
||||||
|
{ name: 'bad1', value: 'v', domain: 'localhost' },
|
||||||
|
{ name: 'bad2', value: 'v', domain: '.corp.internal' },
|
||||||
|
{ name: 'bad3', value: 'v', domain: '169.254.169.254' },
|
||||||
|
{ name: 'bad4', value: 42, domain: 'example.com' },
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
expect(kept.map((c: any) => c.name)).toEqual(['ok']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoreSessionState: missing file → false, corrupt file → quarantined to .corrupt', async () => {
|
||||||
|
const bmNeverCalled = { closeAllPages() { throw new Error('must not restore'); } } as any;
|
||||||
|
expect(await restoreSessionState(bmNeverCalled, path.join(tmpRoot, 'nope.json'))).toBe(false);
|
||||||
|
const corrupt = path.join(tmpRoot, 'corrupt.json');
|
||||||
|
fs.writeFileSync(corrupt, '{oops');
|
||||||
|
expect(await restoreSessionState(bmNeverCalled, corrupt)).toBe(false);
|
||||||
|
expect(fs.existsSync(corrupt)).toBe(false); // moved aside, won't block every future launch
|
||||||
|
expect(fs.existsSync(`${corrupt}.corrupt`)).toBe(true); // forensic artifact kept (R3)
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persistSessionState is a no-op in headed mode (profile owns state)', async () => {
|
||||||
|
const file = path.join(tmpRoot, 'headed.json');
|
||||||
|
const bm = {
|
||||||
|
getConnectionMode: () => 'headed',
|
||||||
|
saveState() { throw new Error('must not snapshot headed session'); },
|
||||||
|
} as any;
|
||||||
|
await persistSessionState(bm, file);
|
||||||
|
expect(fs.existsSync(file)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('session-persist round-trip (real Chromium)', () => {
|
||||||
|
test('cookie + localStorage + URL survive teardown → relaunch', async () => {
|
||||||
|
const { BrowserManager } = await import('../src/browser-manager');
|
||||||
|
const { startTestServer } = await import('./test-server');
|
||||||
|
const { server, url } = startTestServer(0);
|
||||||
|
const stateFile = path.join(tmpRoot, 'roundtrip.json');
|
||||||
|
|
||||||
|
const bm1 = new BrowserManager();
|
||||||
|
await bm1.launch();
|
||||||
|
try {
|
||||||
|
const page = bm1.getPage();
|
||||||
|
await page.goto(`${url}/basic.html`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.cookie = 'session_marker=alive-after-restart; path=/';
|
||||||
|
localStorage.setItem('auth_marker', 'still-logged-in');
|
||||||
|
});
|
||||||
|
await persistSessionState(bm1, stateFile);
|
||||||
|
} finally {
|
||||||
|
await bm1.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// File on disk is owner-only (cookies are secrets).
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
expect(fs.statSync(stateFile).mode & 0o777).toBe(0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bm2 = new BrowserManager();
|
||||||
|
await bm2.launch();
|
||||||
|
try {
|
||||||
|
expect(await restoreSessionState(bm2, stateFile)).toBe(true);
|
||||||
|
const page = bm2.getPage();
|
||||||
|
expect(page.url()).toContain('/basic.html');
|
||||||
|
const marker = await page.evaluate(() => ({
|
||||||
|
cookie: document.cookie,
|
||||||
|
auth: localStorage.getItem('auth_marker'),
|
||||||
|
}));
|
||||||
|
expect(marker.cookie).toContain('session_marker=alive-after-restart');
|
||||||
|
expect(marker.auth).toBe('still-logged-in');
|
||||||
|
} finally {
|
||||||
|
await bm2.close();
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('server wiring (static tripwire)', () => {
|
||||||
|
const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '..', 'src', 'server.ts'), 'utf-8');
|
||||||
|
|
||||||
|
test('start() restores and schedules interval snapshots behind the gate', () => {
|
||||||
|
expect(SERVER_SRC).toContain('isSessionPersistEnabled()');
|
||||||
|
expect(SERVER_SRC).toContain('restoreSessionState(browserManager');
|
||||||
|
expect(SERVER_SRC).toContain('sessionPersistIntervalMs()');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shutdown() takes a final snapshot BEFORE closing the browser', () => {
|
||||||
|
const shutdownStart = SERVER_SRC.indexOf('async function shutdown(');
|
||||||
|
const persistAt = SERVER_SRC.indexOf('persistSessionState(cfgBrowserManager', shutdownStart);
|
||||||
|
const closeAt = SERVER_SRC.indexOf('await cfgBrowserManager.close()', shutdownStart);
|
||||||
|
expect(persistAt).toBeGreaterThan(shutdownStart);
|
||||||
|
expect(closeAt).toBeGreaterThan(persistAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user