Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions scripts/homeSentinelSnapshot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import test from 'node:test';

// The home screen sits in a tab, and that tab's path is the sentinel string
// `'HOME'` rather than a file. `serializeState` filtered on `path !== ''`, so
// the sentinel was written into the session snapshot; `restoreState` accepted
// any non-empty string, so it came back; and startup then asked the backend to
// read a file called `HOME`. The failure left a phantom tab that can never be
// read, and a window whose only tab was the home screen came back empty.
//
// `hasRealFilePath` in utils/tabFileActions.ts is the project's existing answer
// to "is this path a file" — every other caller already used it.
//
// These tests drive the real TabManager, so they lock the behaviour rather than
// the wording. The restore-loop half lives in sessionRestoreResilience.test.ts,
// which already has the window-session harness.

const g = globalThis as any;
const runeEffect = (fn: () => void) => {
void fn;
};
runeEffect.root = (fn: () => unknown) => fn();
g.$state = (value: unknown) => value;
g.$state.raw = (value: unknown) => value;
g.$state.snapshot = (value: unknown) => value;
g.$derived = (value: unknown) => value;
g.$derived.by = (fn: () => unknown) => fn();
g.$effect = runeEffect;
g.window = g.window ?? {};

const localStore = new Map<string, string>();
g.localStorage = {
getItem: (key: string) => (localStore.has(key) ? localStore.get(key)! : null),
setItem: (key: string, value: string) => void localStore.set(key, String(value)),
removeItem: (key: string) => void localStore.delete(key),
clear: () => localStore.clear(),
};
g.window.__TAURI_INTERNALS__ = {
metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } },
invoke: (cmd: string) => Promise.resolve(cmd === 'get_os_type' ? 'macos' : null),
};

const { tabManager } = await import('../src/lib/stores/tabs.svelte.js');

function reset() {
tabManager.closeAll();
localStore.clear();
}

test('a session snapshot never carries the home tab', () => {
reset();
tabManager.addTab('/notes/a.md');
tabManager.addHomeTab();

const snapshot = JSON.parse(tabManager.serializeState());

assert.deepEqual(
snapshot.tabs.map((tab: { path: string }) => tab.path),
['/notes/a.md'],
);
});

test('a window showing only the home tab writes an empty snapshot, not a HOME one', () => {
reset();
tabManager.addHomeTab();

assert.deepEqual(JSON.parse(tabManager.serializeState()).tabs, []);
});

test('untitled tabs are still excluded from the snapshot', () => {
// The filter this changes also carried the untitled rule; keep it honest.
reset();
tabManager.addTab('/notes/a.md');
tabManager.addNewTab();

assert.deepEqual(
JSON.parse(tabManager.serializeState()).tabs.map((tab: { path: string }) => tab.path),
['/notes/a.md'],
);
});

test('a HOME entry in an older snapshot is not restored as a tab', () => {
reset();
// Builds before this fix wrote the sentinel into the snapshot, and those
// snapshots are already on users' disks — so the read side has to reject it
// too, not just the write side.
tabManager.restoreState(
JSON.stringify({
version: 2,
activeTabId: 'home-id',
tabs: [
{ id: 'home-id', path: 'HOME', title: 'Home' },
{ id: 'doc-id', path: '/notes/a.md', title: 'a.md' },
],
}),
);

assert.deepEqual(
tabManager.tabs.map((tab) => tab.path),
['/notes/a.md'],
);
// The stale active id pointed at the rejected entry; the window must not
// come back with nothing selected.
assert.equal(tabManager.activeTabId, tabManager.tabs[0].id);
});

test('a snapshot of nothing but HOME restores no tabs at all', () => {
reset();
tabManager.restoreState(
JSON.stringify({ version: 2, activeTabId: 'home-id', tabs: [{ id: 'home-id', path: 'HOME', title: 'Home' }] }),
);

assert.deepEqual(tabManager.tabs, []);
assert.equal(tabManager.activeTabId, null);
});
31 changes: 23 additions & 8 deletions scripts/interruptedSessionRestore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,35 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';

// The wiring behind the behaviour that `sessionRestoreResilience.test.ts`
// exercises: the breadcrumb key exists, it is written before the work and
// settled afterwards no matter how the pass ends, and an interrupted pass is
// never answered by deleting the snapshot.

const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8');
const session = readFileSync('src/lib/sessions/windowSession.svelte.ts', 'utf8');

test('session restore records work in progress before restoring tabs', () => {
assert.match(viewer, /const RESTORE_IN_PROGRESS_KEY = 'markpad-window-restore-in-progress';/);
assert.match(session, /localStorage\.setItem\(options\.restoreInProgressKey, 'true'\);/);
assert.match(session, /finally \{\s*localStorage\.removeItem\(options\.restoreInProgressKey\);/s);
assert.match(
session,
/const progress: RestoreProgress = \{ running: true, pending: null, deferred, interruptions \};\s*\n\s*writeProgress\(progress\);/,
);
// The record names the document being read, not just "a restore is running":
// that is what lets the next launch skip one document instead of all of them.
assert.match(session, /progress\.pending = tab\.path;\s*\n\s*writeProgress\(progress\);/);
assert.match(session, /finally \{\s*writeProgress\(\{ running: false,/s);
});

test('an interrupted restore discards saved tabs without deleting documents', () => {
assert.match(session, /if \(localStorage\.getItem\(options\.restoreInProgressKey\)\)/);
assert.match(session, /await discardPersistedState\(\);/);
assert.match(viewer, /await windowSession\.discardPersistedState\(\);/);
assert.match(session, /localStorage\.removeItem\(options\.windowStateKey\);/);
assert.match(session, /localStorage\.removeItem\(options\.legacyStateKey\);/);
test('an interrupted restore keeps the snapshot instead of deleting it', () => {
const restore = session.slice(session.indexOf('async function restore'), session.indexOf('async function claimTransferredTab'));
assert.match(restore, /if \(previous\?\.running\)/);
// Deleting the whole snapshot is what made one document cost the user every
// tab they had open. Nothing in restore() may discard it.
assert.doesNotMatch(restore, /discardPersistedState/);
assert.doesNotMatch(restore, /clear_window_state/);
// Explicit exit is a different matter: the user chose it.
assert.match(session, /async function discardPersistedState/);
assert.match(session, /await invoke\('clear_window_state'\);/);
assert.match(viewer, /await windowSession\.discardPersistedState\(\);/);
});
Loading
Loading