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
79 changes: 0 additions & 79 deletions scripts/lastWindowWritesSession.spec.ts

This file was deleted.

68 changes: 68 additions & 0 deletions scripts/perWindowSession.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';

import { test } from 'vitest';

// Every window keeps its own snapshot, keyed by its label on the Rust side. A
// secondary window writes its snapshot whoever else is open, and nobody else's:
// that is what lets each window come back on relaunch, and what ended the quit
// race where the last window to close overwrote main's single slot.

let calls: Array<{ cmd: string; args: any }> = [];

(window as any).__TAURI_INTERNALS__ = {
metadata: {
currentWindow: { label: 'window-abc' },
currentWebview: { windowLabel: 'window-abc', label: 'window-abc' },
},
invoke: (cmd: string, args: any) => {
calls.push({ cmd, args });
return Promise.resolve(cmd === 'get_os_type' ? 'macos' : null);
},
};

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

async function persistFromDetachedWindow(remember: boolean) {
tabManager.closeAll();
tabManager.addTab('/notes/a.md', '# a');
calls = [];
await createWindowSession({
isMainWindow: false,
windowStateKey: 'savedTabsDataV2',
legacyStateKey: 'savedTabsData',
restoreInProgressKey: 'markpad-window-restore-in-progress',
serializeState: () => tabManager.serializeState(),
shouldRestoreState: () => true,
isDisposed: () => false,
restoreState: () => {},
restoredTabs: () => [],
applyRestoredContent: async () => {},
dropRestoredTab: () => {},
canTransfer: () => true,
canDetach: () => true,
transferPayload: () => '',
onTransferClaimed: () => {},
acceptTransferredTab: async () => true,
onError: () => {},
onWarning: () => {},
onInterrupted: () => {},
}).persistState(remember);
}

test('a detached window writes its own snapshot without asking who else is open', async () => {
await persistFromDetachedWindow(true);
const saves = calls.filter((call) => call.cmd === 'save_window_state');
assert.equal(saves.length, 1);
assert.deepEqual(
JSON.parse(saves[0].args.json).tabs.map((tab: { path: string }) => tab.path),
['/notes/a.md'],
);
assert.ok(!calls.some((call) => call.cmd === 'forget_window_state'));
});

test('a window closed on its own is forgotten, not saved', async () => {
await persistFromDetachedWindow(false);
assert.ok(calls.some((call) => call.cmd === 'forget_window_state'));
assert.ok(!calls.some((call) => call.cmd === 'save_window_state'));
});
10 changes: 5 additions & 5 deletions scripts/pinnedTagSave.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,24 +116,24 @@ test('with the window kept open, closing the empty window later does not empty t
await w.fns.closeTabAndWindowIfLast('a');
await w.fns.closeTabAndWindowIfLast('b');
assert.equal(w.isDestroyed(), false);
assert.equal(await w.fns.settleForExit(), true);
assert.equal(await w.fns.settleForExit(async () => true), true);
assert.deepEqual(w.saves.at(-1), ['/notes/b.md']);
});

test('the close review with restore off keeps the dirty tabs it closes in the pin', async () => {
const tabs = twoFiles();
tabs[0].isDirty = true;
const w = pinnedWindow(tabs);
assert.equal(await w.fns.settleForExit(), true);
assert.equal(await w.fns.settleForExit(async () => true), true);
assert.deepEqual(w.tabs.map((tab) => tab.id), ['b'], 'the review closed the dirty tab');
// The handler re-triggers the close after a review, and settles again.
assert.equal(await w.fns.settleForExit(), true);
assert.equal(await w.fns.settleForExit(async () => true), true);
assert.deepEqual(w.saves.at(-1), ['/notes/a.md', '/notes/b.md']);

// A window that lives on after that (an update that failed to install)
// saves what it has open, not the list the review started from.
w.tabs.push({ id: 'c', path: '/notes/c.md', isDirty: false });
assert.equal(await w.fns.settleForExit(), true);
assert.equal(await w.fns.settleForExit(async () => true), true);
assert.deepEqual(w.saves.at(-1), ['/notes/b.md', '/notes/c.md']);
});

Expand Down Expand Up @@ -163,7 +163,7 @@ test('merging a pinned window into another keeps its files in the pin', async ()
test('a pin another window removed stays removed when this window closes', async () => {
const w = pinnedWindow(twoFiles());
w.pin.exists = false;
assert.equal(await w.fns.settleForExit(), true);
assert.equal(await w.fns.settleForExit(async () => true), true);
assert.deepEqual(w.tabManager.windowTag, { name: 'work', color: '#f00', pinned: false }, 'the window still claims the pin');
});

Expand Down
2 changes: 1 addition & 1 deletion scripts/sessionRestoreResilience.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ test('a deferred document is released once Markpad has read it', async () => {
const deferredTab = tabManager.tabs.find((tab) => tab.path === '/big.md')!;
tabManager.setTabRawContent(deferredTab.id, '# big');

await session.persistState();
await session.persistState(true);

assert.equal(breadcrumb(), null, 'the deferral is not permanent');
});
Expand Down
20 changes: 15 additions & 5 deletions scripts/unsavedRecovery.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createUnsavedRecovery, parseRecovery, recoverySnapshot, restoreRecords } from '../src/lib/sessions/unsavedRecovery.js';
import { claimableRecords, createUnsavedRecovery, parseRecovery, recoverySnapshot, restoreRecords } from '../src/lib/sessions/unsavedRecovery.js';
import { buildTransferredTab, type TransferableTab } from '../src/lib/utils/tabTransfer.js';
import { asRendererLine } from '../src/lib/utils/lineCoordinates.js';
import { HOME_TAB_PATH } from '../src/lib/utils/homeTab.js';
Expand Down Expand Up @@ -61,11 +61,11 @@ test('restore replaces clean session copies, keeps baselines, and skips live win
{ ...document, path: '/other.md' },
]);
const dirtyId = target.tabs[1].id;
const consumed = restoreRecords(target, [
const consumed = restoreRecords(target, claimableRecords([
['main', JSON.stringify([{ ...document, path: '/notes.md', originalContent: 'saved', isEditing: false, isSplit: true }])],
['window-2', JSON.stringify([{ ...document, path: '/other.md', rawContent: 'another unsaved copy' }, document])],
['window-3', JSON.stringify([{ ...document, rawContent: 'still open elsewhere' }])],
], new Set(['window-3']), 'main', (error) => { throw error; });
], 'main', true, new Set(['window-3'])), 'main', (error) => { throw error; });
assert.deepEqual(consumed, ['window-2']);
assert.deepEqual(target.tabs.map((tab) => tab.path), ['/other.md', '/notes.md', '/other.md', '']);
assert.equal(target.tabs[0].id, dirtyId);
Expand All @@ -83,18 +83,28 @@ test('a corrupt record of another window is reported, skipped, and left unconsum
['window-2', ''],
['window-3', JSON.stringify([document, {}])],
['window-4', JSON.stringify([document])],
], new Set(), 'main', (error) => errors.push(error));
], 'main', (error) => errors.push(error));
assert.equal(errors.length, 2);
assert.deepEqual(consumed, ['window-4']);
assert.equal(target.tabs.length, 1);
});

test('a corrupt record of this window throws, so the caller never overwrites it', () => {
const target = manager();
assert.throws(() => restoreRecords(target, [['main', '{']], new Set(), 'main', () => {}));
assert.throws(() => restoreRecords(target, [['main', '{']], 'main', () => {}));
assert.equal(target.tabs.length, 0);
});

test('a backup follows its window: main takes its own and the orphans, a reopened window only its own', () => {
const records: [string, string][] = [['main', ''], ['window-crashed', ''], ['window-open', ''], ['window-reopened', '']];
const owned = new Set(['window-open', 'window-reopened']);
const labels = (taken: [string, string][]) => taken.map(([label]) => label);
assert.deepEqual(labels(claimableRecords(records, 'main', true, owned)), ['main', 'window-crashed']);
assert.deepEqual(labels(claimableRecords(records, 'window-reopened', false, owned)), ['window-reopened']);
// Restore off: no window is reopened, so every backup lands in main as before.
assert.deepEqual(labels(claimableRecords(records, 'main', true, new Set())), labels(records));
});

test('continuous changes reach disk within the bounded background delay', async (context) => {
context.mock.timers.enable({ apis: ['setTimeout'] });
const writes: string[] = [];
Expand Down
2 changes: 1 addition & 1 deletion scripts/viewModeWithoutSaving.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ test('closing the window still reviews unsaved tabs', () => {
assert.doesNotMatch(exit, /askCustom/, 'quit asks nothing of its own; the close handler does the asking');

const closeHandler = sliceBetween(viewer, 'appWindow.onCloseRequested', 'onDragDropEvent');
assert.match(closeHandler, /await settleForExit\(\)/, 'the close still runs the review');
assert.match(closeHandler, /await settleForExit\(/, 'the close still runs the review');
assert.match(pluck('settleForExit'), /canCloseTab,\n/, 'the walk still runs the per-tab dialog');
});

Expand Down
10 changes: 5 additions & 5 deletions scripts/windowClosePerTab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,15 @@ test('the close is prevented synchronously before the walk starts', () => {
// Not the re-entry guard's `preventDefault`: the one between that guard and
// the walk.
const guardEnd = offsetOf(handler, 'return;', offsetOf(handler, 'if (isCloseWalkActive)'));
const walk = offsetOf(handler, 'await settleForExit()');
const walk = offsetOf(handler, 'await settleForExit(');
const prevent = handler.lastIndexOf('event.preventDefault()', walk);
assert.ok(guardEnd < prevent, 'the close is prevented before the walk starts');
assert.match(settle(), /reviewDirtyTabs\(\{/, 'and the walk is what settleForExit runs');
});

test('the window closes only after every dirty tab is resolved', () => {
const handler = closeHandler();
const walk = offsetOf(handler, 'await settleForExit()');
const walk = offsetOf(handler, 'await settleForExit(');
const close = offsetOf(handler, 'appWindow.close()', walk);
assert.ok(walk < close, 'the window closes after the walk, not during it');
});
Expand All @@ -142,10 +142,10 @@ test('a cancelled walk stops the handler before it persists or closes', () => {
const body = settle();
const walk = offsetOf(body, 'reviewDirtyTabs({');
const bail = offsetOf(body, 'return false;', offsetOf(body, 'if (!resolved)', walk));
assert.ok(bail < offsetOf(body, 'persistWindowState()', walk), 'cancel returns before the snapshot');
assert.ok(bail < offsetOf(body, 'persistWindowState(', walk), 'cancel returns before the snapshot');

const handler = closeHandler();
const stop = offsetOf(handler, 'return;', offsetOf(handler, 'if (!(await settleForExit())) {'));
const stop = offsetOf(handler, 'return;', offsetOf(handler, 'if (!(await settleForExit(endsSession))) {'));
assert.ok(stop < offsetOf(handler, 'appWindow.close()', stop), 'and before the close is re-triggered');
});

Expand Down Expand Up @@ -181,7 +181,7 @@ test('save-as writes a snapshot rather than the buffer as it stands', () => {
});

test('the restore-on-reopen branch persists window state via the shared helper', () => {
assert.match(settle(), /persistWindowState\(\);/);
assert.match(settle(), /persistWindowState\(await remember\(\)\);/);
// no durable-write experiment left behind
assert.doesNotMatch(viewer, /saveSessionState|sessionState\.js/);
});
2 changes: 1 addition & 1 deletion scripts/windowStateRestore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ test('startup restore reads content from disk, not from the snapshot', () => {
test('the close flow resolves dirty tabs before serializing window state', () => {
const body = settleForExit();
const walk = offsetOf(body, 'reviewDirtyTabs({');
const persist = offsetOf(body, 'persistWindowState()');
const persist = offsetOf(body, 'persistWindowState(');
assert.ok(walk < persist, 'dirty tabs must be resolved before the snapshot is written');
});

Expand Down
38 changes: 27 additions & 11 deletions scripts/windowTagPerWindow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { test } from 'vitest';
* here rather than read:
*
* 1. `TabManager.windowTag`'s own initial value;
* 2. session restore, which is where a window gets its previous state back;
* 2. session restore, which gives a window back only its own previous state;
* 3. the tab-transfer claim, the only channel through which one window's
* content reaches another.
*
Expand Down Expand Up @@ -63,6 +63,8 @@ const TRANSFER_PAYLOAD = JSON.stringify({
});

let invokeCalls: Array<{ cmd: string; args: any }> = [];
/** What Rust holds for this window's label: snapshots are keyed by the caller. */
let ownSnapshot: string | null = null;

(window as any).__TAURI_INTERNALS__ = {
// A detached window, which is what `create_transfer_window` builds.
Expand All @@ -76,7 +78,7 @@ let invokeCalls: Array<{ cmd: string; args: any }> = [];
case 'get_os_type':
return Promise.resolve('macos');
case 'load_window_state':
return Promise.resolve(TAGGED_SNAPSHOT);
return Promise.resolve(ownSnapshot);
case 'claim_detached_tab':
return Promise.resolve(TRANSFER_PAYLOAD);
case 'read_file_content_checked':
Expand Down Expand Up @@ -132,30 +134,44 @@ function reset() {
tabManager.setWindowTag(null);
localStorage.clear();
invokeCalls = [];
ownSnapshot = null;
}

test('a freshly constructed TabManager holds no window tag', () => {
assert.equal(INITIAL_WINDOW_TAG, null, 'the store starts a window off under a tag');
});

test('a detached window does not restore, so it cannot pick up the tag in the snapshot', async () => {
test('a new window has no snapshot of its own, so it cannot pick up another window’s tag', async () => {
// Main's snapshot from a build that kept it in localStorage, which every
// window shares. Only main may read it.
reset();
localStorage.setItem(WINDOW_STATE_KEY, TAGGED_SNAPSHOT);

await makeSession(false).restore();
const restored = await makeSession(false).restore();

assert.equal(restored, false, 'a new window reported a snapshot of its own');
assert.equal(tabManager.windowTag, null, 'a new window came up wearing the saved window tag');
assert.deepEqual(tabManager.tabs, [], 'a new window restored the previous session');
assert.equal(
invokeCalls.filter((call) => call.cmd === 'load_window_state').length,
0,
'a new window read the saved session at all',
);
});

test('a reopened window gets its own tag back', async () => {
// The window was open when the app quit, and comes back under its label.
reset();
ownSnapshot = TAGGED_SNAPSHOT;

const restored = await makeSession(false).restore();

assert.equal(restored, true);
assert.deepEqual(tabManager.windowTag, { name: 'Research', color: '#188038', pinned: true });
assert.equal(tabManager.tabs.length, 1);
});

test('the main window does restore its own tag — this is not a test that restore is broken', async () => {
// The fence. Without it the test above passes for a build that simply never
// restores anything, which is a much worse defect than the one it guards.
// The fence. Without it the first restore test passes for a build that
// simply never restores anything, which is a much worse defect than the
// one it guards.
reset();
ownSnapshot = TAGGED_SNAPSHOT;

await makeSession(true).restore();

Expand Down
Loading
Loading