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
2 changes: 1 addition & 1 deletion scripts/backgroundEditorPosition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ test('both gates that drop the view state ask for the anchor first', () => {
// `editorViewState`: `serializeState` omits it and `restoreState` seeds
// null, and `tabTransfer.ts` excludes it as a live Monaco object.
const store = readSource('src/lib/stores/tabs.svelte.ts');
const serialize = sliceFrom(store, 'serializeState(): string {');
const serialize = sliceFrom(store, 'serializeState(extra: Record<string, unknown> = {}): string {');
assert.match(serialize, /anchorLine: outgoingTabAnchorLine\(t, this\.activeTabId\)/);

const viewer = readSource('src/lib/MarkdownViewer.svelte');
Expand Down
2 changes: 1 addition & 1 deletion scripts/coldStartDoesNotStealFocus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ test('the main window is shown without being activated on a cold start', () => {
// unconditionally — pulling the user out of whatever they had switched to.
// The label and the one-shot flag together are what make it the FIRST show
// of the MAIN window that stays quiet.
assert.match(showWindow, /window\.label\(\)\s*==\s*"main"/);
assert.match(showWindow, /window\.label\(\)\s*==\s*LAUNCH_LABEL/);
assert.match(showWindow, /MAIN_WINDOW_SHOWN\.swap\(true,/);

// Not vacuous: the other callers — a detached tab window, the close walk
Expand Down
2 changes: 1 addition & 1 deletion scripts/editorPositionFlush.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ test('the window-state snapshot flushes too', () => {
// fields and the editor has not come down yet.
const serialize = sliceFrom(viewer, 'serializeState: () => {');
const flushAt = offsetOf(serialize, 'flushPositionTo(tabManager.activeTabId)');
const stateAt = offsetOf(serialize, 'return tabManager.serializeState();');
const stateAt = offsetOf(serialize, 'return windowStateJson();');
assert.ok(flushAt < stateAt);
});

Expand Down
42 changes: 36 additions & 6 deletions scripts/folderSidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function handleInvoke(command: string, args: Record<string, any>): unknown {
transformCallback: (fn: unknown) => fn,
};

const { FolderManager, LAST_FOLDER_KEY, folderName, isInside } = await import('../src/lib/stores/folder.svelte.js');
const { FolderManager, LAST_FOLDER_KEY, folderName, isInside, snapshotFolder } = await import('../src/lib/stores/folder.svelte.js');

function freshDisk() {
disk = {};
Expand All @@ -67,7 +67,8 @@ test('opening a folder reads and watches only its top level', async () => {
assert.deepEqual(Object.keys(folder.entries), ['/notes']);
assert.deepEqual([...watched], ['/notes']);
assert.equal(folder.rootName, 'notes');
assert.equal(localStorage.getItem(LAST_FOLDER_KEY), '/notes');
// Each window keeps its folder in its own snapshot, not in shared storage.
assert.equal(localStorage.getItem(LAST_FOLDER_KEY), null);
});

test('collapsing a folder stops watching it and everything expanded under it', async () => {
Expand Down Expand Up @@ -112,23 +113,52 @@ test('a subfolder that vanished collapses with everything under it', async () =>
assert.deepEqual([...watched], ['/notes']);
});

test('closing forgets the folder, its watches and the saved root', async () => {
test('closing forgets the folder and its watches', async () => {
freshDisk();
const folder = new FolderManager();
await folder.open('/notes');
await folder.close();
assert.equal(folder.root, null);
assert.equal(watched.size, 0);
assert.equal(localStorage.getItem(LAST_FOLDER_KEY), null);
});

test('a saved folder that no longer exists is dropped on restore, not shown as an error', async () => {
freshDisk();
localStorage.setItem(LAST_FOLDER_KEY, '/gone');
const folder = new FolderManager();
await folder.restore();
await folder.restore('/gone');
assert.equal(folder.root, null);
assert.equal(folder.error, null);
});

test('each window reopens the folder its own snapshot names', async () => {
freshDisk();
const first = new FolderManager();
const second = new FolderManager();
await first.restore(snapshotFolder(JSON.stringify({ version: 2, tabs: [], folderRoot: '/notes' })));
await second.restore(snapshotFolder(JSON.stringify({ version: 2, tabs: [] })));
assert.equal(first.root, '/notes');
assert.equal(second.root, null);
assert.equal(snapshotFolder('not json'), null);
});

test('the install-wide folder of older builds goes to the first window, once', async () => {
freshDisk();
localStorage.setItem(LAST_FOLDER_KEY, '/notes');
const launch = new FolderManager();
await launch.restore(null);
assert.equal(launch.root, '/notes');
assert.equal(localStorage.getItem(LAST_FOLDER_KEY), null);
const later = new FolderManager();
await later.restore(null);
assert.equal(later.root, null);
});

test('a window’s own folder wins over the migrated one, which is still removed', async () => {
freshDisk();
localStorage.setItem(LAST_FOLDER_KEY, '/notes/drafts');
const folder = new FolderManager();
await folder.restore('/notes');
assert.equal(folder.root, '/notes');
assert.equal(localStorage.getItem(LAST_FOLDER_KEY), null);
});

Expand Down
11 changes: 6 additions & 5 deletions scripts/macosOpenDocumentPaths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ const tauriLib = readRustBackend();
const viewer = readSource('src/lib/MarkdownViewer.svelte');

test('macOS open-document events preserve every delivered file path', () => {
assert.match(runtime, /startup_files: Mutex<Vec<String>>/);
// Every delivered url is pushed, and the queue is drained whole: the
assert.match(runtime, /startup_files: Mutex<Option<Vec<String>>>/);
// Every delivered url is pushed, and the queue is taken whole: the
// defect this guards against was consuming only `urls.first()`.
assert.match(tauriLib, /for url in urls/);
assert.match(tauriLib, /startup_files\)\s*\.push\(path_str\.clone\(\)\)/);
assert.match(runtime, /startup_files\)\s*\.drain\(\.\.\)\s*\.collect\(\)/);
assert.match(runtime, /for path in startup_files\.into_iter\(\)\.rev\(\)/);
assert.match(tauriLib, /deliver_opened_path\(/);
assert.match(runtime, /paths\.push\(path\.clone\(\)\)/);
assert.match(runtime, /startup_files\)\.take\(\)/);
assert.match(runtime, /for path in opened\.into_iter\(\)\.rev\(\)/);
assert.match(viewer, /for \(const path of args\) await loadMarkdown\(path\);/);
});

Expand Down
1 change: 0 additions & 1 deletion scripts/newWindowClaim.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ const { tabManager } = await import('../src/lib/stores/tabs.svelte.js');
test('a new window claims nothing and opens with no tabs', async () => {
const errors: unknown[] = [];
const session = createWindowSession({
isMainWindow: false,
windowStateKey: 'savedTabsDataV2',
legacyStateKey: 'savedTabsData',
restoreInProgressKey: 'markpad-window-restore-in-progress',
Expand Down
62 changes: 54 additions & 8 deletions scripts/perWindowSession.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';

import { test } from 'vitest';
import { test, vi } 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:
Expand All @@ -23,12 +23,8 @@ let calls: Array<{ cmd: string; args: any }> = [];
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,
function makeSession() {
return createWindowSession({
windowStateKey: 'savedTabsDataV2',
legacyStateKey: 'savedTabsData',
restoreInProgressKey: 'markpad-window-restore-in-progress',
Expand All @@ -47,9 +43,18 @@ async function persistFromDetachedWindow(remember: boolean) {
onError: () => {},
onWarning: () => {},
onInterrupted: () => {},
}).persistState(remember);
});
}

async function persistFromDetachedWindow(remember: boolean) {
tabManager.closeAll();
tabManager.addTab('/notes/a.md', '# a');
calls = [];
await makeSession().persistState(remember);
}

const saves = () => calls.filter((call) => call.cmd === 'save_window_state').map((call) => call.args.json);

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');
Expand All @@ -66,3 +71,44 @@ test('a window closed on its own is forgotten, not saved', async () => {
assert.ok(calls.some((call) => call.cmd === 'forget_window_state'));
assert.ok(!calls.some((call) => call.cmd === 'save_window_state'));
});

test('a window records its state while open, within the bounded delay, latest first', async () => {
vi.useFakeTimers();
try {
tabManager.closeAll();
const session = makeSession();
calls = [];
session.record('before restore');
await vi.advanceTimersByTimeAsync(1000);
assert.deepEqual(saves(), [], 'recorded over the snapshot it had not read yet');
await session.restore();
// The restore writes the window's starting snapshot, an empty one included.
assert.equal(saves().length, 1);
session.record('one');
session.record('two');
await vi.advanceTimersByTimeAsync(499);
assert.equal(saves().length, 1, 'wrote before the bounded delay');
await vi.advanceTimersByTimeAsync(1);
assert.deepEqual(saves().slice(1), ['two']);
} finally {
vi.useRealTimers();
}
});

test('a window forgotten with a write pending is not written back', async () => {
vi.useFakeTimers();
try {
tabManager.closeAll();
const session = makeSession();
await session.restore();
calls = [];
session.record('pending');
await session.persistState(false);
session.record('after forget');
await vi.advanceTimersByTimeAsync(5000);
assert.deepEqual(saves(), []);
assert.equal(calls.at(-1)?.cmd, 'forget_window_state');
} finally {
vi.useRealTimers();
}
});
3 changes: 1 addition & 2 deletions scripts/sessionRestoreResilience.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,11 @@ const warnings: string[] = [];
const errors: string[] = [];
/** What the session asked the UI to tell the user, before any wording. */
const notices: Array<{ deferredPath: string | null }> = [];
/** The "Reopen Previous Tabs" setting, as the session sees it. */
/** The "Restore windows on launch" setting, as the session sees it. */
let restoreEnabled = true;

function makeSession() {
return createWindowSession({
isMainWindow: true,
windowStateKey: WINDOW_STATE_KEY,
legacyStateKey: LEGACY_STATE_KEY,
restoreInProgressKey: RESTORE_IN_PROGRESS_KEY,
Expand Down
2 changes: 1 addition & 1 deletion scripts/settingsSemantics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* * "Word Count" — rendered inside the status bar. With the status bar off,
* turning it on shows nothing at all.
*
* A fourth — that "Reopen Previous Tabs" also decides whether resolved tabs
* A fourth — that "Restore windows on launch" also decides whether resolved tabs
* stay open when a window closes — was left alone. Saying so needs a real
* tooltip, and a `title` attribute is not one: it only changes the cursor and
* waits a second before a browser chrome bubble the app does not control.
Expand Down
2 changes: 1 addition & 1 deletion scripts/tabTransfer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ test('insertTransferredTab is a thin wrapper: build, push, activate, return the

test('serializeState still persists window shape only (untouched by transfer)', () => {
const tabs = readSource('src/lib/stores/tabs.svelte.ts');
const fn = sliceBetween(tabs, 'serializeState()', 'restoreState(');
const fn = sliceBetween(tabs, 'serializeState(extra', 'restoreState(');
assert.doesNotMatch(fn, /rawContent/);
assert.doesNotMatch(fn, /TransferableTab/);
});
Expand Down
53 changes: 34 additions & 19 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 { claimableRecords, createUnsavedRecovery, parseRecovery, recoverySnapshot, restoreRecords } from '../src/lib/sessions/unsavedRecovery.js';
import { createBoundedWriter, 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 @@ -55,17 +55,16 @@ function manager(tabs: TransferableTab[] = []) {
};
}

test('restore replaces clean session copies, keeps baselines, and skips live windows', () => {
test('restore replaces clean session copies and keeps baselines', () => {
const target = manager([
{ ...document, path: '/notes.md', rawContent: 'disk', originalContent: 'disk', isDirty: false },
{ ...document, path: '/other.md' },
]);
const dirtyId = target.tabs[1].id;
const consumed = restoreRecords(target, claimableRecords([
const consumed = restoreRecords(target, [
['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' }])],
], 'main', true, new Set(['window-3'])), 'main', (error) => { throw error; });
], '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 Down Expand Up @@ -95,20 +94,10 @@ test('a corrupt record of this window throws, so the caller never overwrites it'
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[] = [];
const recovery = createUnsavedRecovery({ write: async (json) => { writes.push(json); }, onError: (error) => { throw error; } });
const recovery = createBoundedWriter({ initial: '[]', write: async (json) => { writes.push(json); }, onError: (error) => { throw error; } });
recovery.update('first');
context.mock.timers.tick(400);
recovery.update('latest');
Expand All @@ -126,7 +115,8 @@ test('flush cancels delayed writes and serializes cleanup behind in-flight write
const writes: string[] = [];
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const recovery = createUnsavedRecovery({
const recovery = createBoundedWriter({
initial: '[]',
write: async (json) => { writes.push(json); if (writes.length === 1) await gate; },
onError: (error) => { throw error; },
});
Expand All @@ -144,7 +134,8 @@ test('flush cancels delayed writes and serializes cleanup behind in-flight write
test('failed writes retry without another edit and flush reports failure', async (context) => {
context.mock.timers.enable({ apis: ['setTimeout'] });
let attempts = 0;
const recovery = createUnsavedRecovery({
const recovery = createBoundedWriter({
initial: '[]',
write: async () => { if (++attempts === 1) throw new Error('temporarily unavailable'); },
onError: () => {},
});
Expand All @@ -161,7 +152,8 @@ test('failed writes retry without another edit and flush reports failure', async
test('write failures are reported without blocking later snapshots', async () => {
const errors: unknown[] = [];
const writes: string[] = [];
const recovery = createUnsavedRecovery({
const recovery = createBoundedWriter({
initial: '[]',
write: async (json) => { writes.push(json); if (json === 'bad') throw new Error('disk full'); },
onError: (error) => { errors.push(error); },
});
Expand All @@ -172,6 +164,29 @@ test('write failures are reported without blocking later snapshots', async () =>
recovery.dispose();
});

test('dispose drops a pending write and waits for the one in flight, so a forgotten file stays gone', async (context) => {
context.mock.timers.enable({ apis: ['setTimeout'] });
const writes: string[] = [];
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const writer = createBoundedWriter({
write: async (json) => { writes.push(json); await gate; },
onError: (error) => { throw error; },
});
writer.update('in flight');
context.mock.timers.tick(500);
writer.update('pending');
let settled = false;
const disposed = writer.dispose().then(() => { settled = true; });
await Promise.resolve();
assert.equal(settled, false, 'dispose resolved while a write was still in flight');
release();
await disposed;
context.mock.timers.tick(5000);
writer.update('after dispose');
assert.deepEqual(writes, ['in flight']);
});

test('the close decision commands are spelled the same in the viewer and in Rust', () => {
// Cross-language contract: only the string connects invoke() to the command.
const viewer = readSource('src/lib/MarkdownViewer.svelte');
Expand Down
22 changes: 22 additions & 0 deletions scripts/windowGeometryWhileRunning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { readRustBackend, sliceBetween } from './sourceTree.js';

// The window-state plugin saves only on a clean exit, so after a crash every
// window came back at its default size. Moving or resizing a window has to
// reach the plugin's save while the app runs. Needs a live window to execute;
// the 500 ms bound is `coalesced`, tested in Rust.
const rust = readRustBackend();

test('a move or resize schedules a geometry save', () => {
const handler = sliceBetween(rust, 'pub fn handle_window_event', 'tauri::WindowEvent::Focused(true)');
assert.match(handler, /WindowEvent::Moved\(_\) \| tauri::WindowEvent::Resized\(_\)/);
assert.match(handler, /try_state::<GeometrySaver>\(\)/);
assert.match(rust, /app\.manage\(window_runtime::GeometrySaver::new\(/);
});

test('the running save and the plugin keep the same fields', () => {
assert.match(rust, /\.with_state_flags\(window_runtime::geometry_flags\(\)\)/);
assert.match(rust, /app\.save_window_state\(geometry_flags\(\)\)/);
});
4 changes: 3 additions & 1 deletion scripts/windowStateRestore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ test('v2 snapshots are invisible to legacy builds (Rust file, localStorage keys
// process); both localStorage keys are removed on write, so a downgraded
// build starts a fresh session instead of misreading anything.
const scope = sliceBetween(session, 'async function persistState', 'async function restore');
assert.match(scope, /invoke\('save_window_state'/);
// Through the session's one snapshot writer, which calls the Rust command.
assert.match(scope, /snapshots\.flush\(/);
assert.match(session, /write: \(json\) => invoke\('save_window_state'/);
assert.doesNotMatch(scope, /setItem\(/);
assert.match(scope, /removeItem\(options\.windowStateKey\)/);
assert.match(scope, /removeItem\(options\.legacyStateKey\)/);
Expand Down
Loading
Loading