Files
webmail/lib/mail-export/__tests__/sinks.test.ts
T
Linus Rath 9d4d3261da fix: clear exported mail from the browser's storage and say plainly where an export goes
- The private file an export writes in Firefox and Safari is deleted
  when the app starts and when someone signs in, not only at the next
  export, and at once on sign-out. Close keeps it until its download
  had time to finish. Each tab holds its own files under a Web Lock, so
  one tab's sweep no longer deletes another tab's running export.
- A browser whose private file system can't write files (Safari before
  26) is told the export stays in memory instead of being promised a
  download.
- The export's live region announces only the phase and pauses; the
  count next to it no longer floods screen readers, and the format
  group is named once.
2026-11-01 06:50:13 +01:00

105 lines
4.2 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import { canWritePrivateFile, downloadGraceMs, privateFileSink, sweepPrivateExports } from '@/lib/mail-export/sinks';
/** A tiny origin private file system: one directory of files. */
function fakeOpfs() {
const files = new Map<string, Uint8Array[]>();
const dir = {
getFileHandle: async (name: string) => {
if (!files.has(name)) files.set(name, []);
return {
createWritable: async () => ({
write: async (data: Uint8Array) => { files.get(name)!.push(data); },
close: async () => {},
abort: async () => {},
}),
getFile: async () => new File(files.get(name)! as BlobPart[], name),
};
},
removeEntry: async (name: string) => { files.delete(name); },
keys: async function* () { yield* [...files.keys()]; },
};
const root = {
getDirectoryHandle: async (name: string, opts?: { create?: boolean }) => {
if (name !== 'bulwark-exports') throw new Error('no');
if (!opts?.create && files.size === 0 && !created) throw new DOMException('missing', 'NotFoundError');
created = true;
return dir;
},
removeEntry: vi.fn(async () => { files.clear(); }),
};
let created = false;
return { files, root };
}
/** Web Locks where `held` names belong to another tab. */
function fakeLocks(held: Set<string>) {
return {
request: async (name: string, _o: unknown, cb: (lock: unknown) => unknown) => cb(held.has(name) ? null : { name }),
};
}
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe('sweepPrivateExports', () => {
it('deletes the files no tab holds and leaves another tab\'s running export alone', async () => {
const opfs = fakeOpfs();
opfs.files.set('1-old', [new Uint8Array(3)]);
opfs.files.set('2-busy', [new Uint8Array(3)]);
vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set(['bulwark-export-file:2-busy'])) });
await sweepPrivateExports();
expect([...opfs.files.keys()]).toEqual(['2-busy']);
});
it('sweeps nothing without Web Locks unless everyone signed out', async () => {
const opfs = fakeOpfs();
opfs.files.set('1-old', [new Uint8Array(3)]);
vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root } });
await sweepPrivateExports();
expect(opfs.files.size).toBe(1);
await sweepPrivateExports({ force: true });
expect(opfs.root.removeEntry).toHaveBeenCalledWith('bulwark-exports', { recursive: true });
});
});
describe('privateFileSink', () => {
it('keeps the file while its download may still read it, and deletes it after', async () => {
vi.useFakeTimers();
const opfs = fakeOpfs();
vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) });
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: vi.fn() }));
const sink = (await privateFileSink('a.mbox', 'application/mbox'))!;
await sink.write(new Uint8Array(10));
const file = await sink.close();
const gone = file.discard();
await vi.advanceTimersByTimeAsync(30_000);
expect(opfs.files.size).toBe(1);
await vi.advanceTimersByTimeAsync(downloadGraceMs(10));
await gone;
expect(opfs.files.size).toBe(0);
});
it('deletes at once on sign-out', async () => {
const opfs = fakeOpfs();
vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) });
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: vi.fn() }));
const sink = (await privateFileSink('a.mbox', 'application/mbox'))!;
await sink.write(new Uint8Array(10));
await (await sink.close()).discard(true);
expect(opfs.files.size).toBe(0);
});
});
describe('canWritePrivateFile', () => {
it('is false where the private file system has no writable files (Safari before 26)', () => {
vi.stubGlobal('navigator', { storage: { getDirectory: async () => ({}) } });
vi.stubGlobal('FileSystemFileHandle', class {});
expect(canWritePrivateFile()).toBe(false);
vi.stubGlobal('FileSystemFileHandle', class { createWritable() {} });
expect(canWritePrivateFile()).toBe(true);
});
});