loadQueue() had exactly one call site in the entire frontend: the /upload route's onMount. So after a reload, an iOS tab discard or a PWA relaunch, staged photos sat in IndexedDB while the badge read 0 and nothing sent them — unless the guest happened to navigate back to /upload, which they have no reason to do, having already been shown a success. The photo never leaves the phone and the guest is never told. The root cause was narrower than "loadQueue isn't called enough". requeueRetriable() read IndexedDB but only .map()'d over whatever the in-memory store already held, so it could reset statuses and never ADD an entry — and processQueue reads only that store. That is why the `online` listener and the SSE resume hooks, which both call it, could not recover a cold start either. It now REBUILDS the store from IndexedDB, which makes all three resume paths work. Rebuilding needs one guard: entryToQueueItem downgrades `uploading` to `pending` with progress 0, and this runs on every `online` event and every SSE reconnect, so a blind rebuild would visibly reset the progress bar of a request still on the wire. In-flight items are carried over by id. Hydration is module-level, SSR-guarded and idempotent, re-armed via onSetAuth/onClearAuth because login is a client-side goto() — no module re-import, no onMount re-run — so a hydration that no-oped for lack of a token gets a second chance. Module level rather than a layout onMount because +layout.svelte already imports this module on every entry point, it matches the file's own bindOnline()/bindSse() pattern, and a store owning its own persistence keeps the layout free of a concern it cannot test. auth.ts does not import this module, so no cycle. The burst-queue e2e test no longer navigates to /upload after its reload — it now asserts the resume happens wherever the reload lands, which is the actual regression guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
223 lines
9.5 KiB
TypeScript
223 lines
9.5 KiB
TypeScript
/**
|
|
* Client upload-queue under a realistic burst — the scenario an event actually
|
|
* produces (a guest multi-selects 10-20 photos at once) and the one the
|
|
* server-side load test could NOT cover, because that test hit POST /upload
|
|
* directly and bypassed the browser queue entirely.
|
|
*
|
|
* Drives the REAL client path (UploadSheet → /upload → addToQueue →
|
|
* processQueue → XHR) and asserts the four properties that matter for not
|
|
* losing a guest's photos:
|
|
*
|
|
* 1. SERIAL drain — the queue uploads ONE file at a time per device
|
|
* (processQueue's find-next-pending loop), never a
|
|
* parallel fan-out. Proven by counting how many upload
|
|
* requests sit in the intercept handler at once.
|
|
* 2. PERSISTENCE — every staged file is written to IndexedDB with its
|
|
* blob before it's sent, and the blob is dropped only
|
|
* after the upload succeeds. Read straight out of IDB
|
|
* mid-burst.
|
|
* 3. ALL LAND — every file in the burst is created server-side.
|
|
* 4. RESUME ON RELOAD — a hard reload mid-burst (tab closed / PWA killed)
|
|
* resumes the remaining uploads from IndexedDB via
|
|
* loadQueue(), losing nothing.
|
|
*
|
|
* A tiny per-upload latency is injected via route interception so the serial
|
|
* drain is observable and there's a window to reload mid-burst — no need for
|
|
* large fixture files.
|
|
*/
|
|
import { test, expect } from '../../fixtures/test';
|
|
import { skipIfNoIdbBlobs } from '../../helpers/webkit';
|
|
import { FeedPage, UploadSheet } from '../../page-objects';
|
|
import { mkdtempSync, copyFileSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import type { Page } from '@playwright/test';
|
|
|
|
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
|
const BURST = 16; // in the 10-20 "multi-select" range
|
|
|
|
// Make BURST distinct files. The queue dedupes on name+size+lastModified, so
|
|
// distinct NAMES are enough to keep them from collapsing into one item — content
|
|
// can be identical (we only assert rows are created, not what they decode to).
|
|
function makeBurstFiles(): { dir: string; paths: string[] } {
|
|
const dir = mkdtempSync(join(tmpdir(), 'eventsnap-burst-'));
|
|
const paths = Array.from({ length: BURST }, (_, i) => {
|
|
const p = join(dir, `burst_${String(i).padStart(2, '0')}.jpg`);
|
|
copyFileSync(SAMPLE_JPG, p);
|
|
return p;
|
|
});
|
|
return { dir, paths };
|
|
}
|
|
|
|
// White-box: read the queue rows straight from IndexedDB, including whether the
|
|
// blob is still attached — so we can prove blobs are kept until upload and
|
|
// dropped after. Mirrors the reader in offline-resume.spec.
|
|
async function queueRows(page: Page): Promise<Array<{ status: string; hasBlob: boolean }>> {
|
|
return page.evaluate(async () => {
|
|
return new Promise<Array<{ status: string; hasBlob: boolean }>>((resolve, reject) => {
|
|
const req = indexedDB.open('eventsnap-uploads', 3);
|
|
req.onerror = () => reject(req.error);
|
|
req.onsuccess = () => {
|
|
const dbh = req.result;
|
|
const tx = dbh.transaction('queue', 'readonly');
|
|
const all = tx.objectStore('queue').getAll();
|
|
all.onsuccess = () =>
|
|
resolve(all.result.map((r: any) => ({ status: r.status, hasBlob: r.blob != null })));
|
|
all.onerror = () => reject(all.error);
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Intercept POST /upload to (a) inject latency so the serial drain is observable
|
|
* and (b) count how many uploads are in the handler simultaneously. A serial
|
|
* client never has more than one in flight; a parallel fan-out would spike.
|
|
* Returns a `peak()` accessor for the max concurrency seen.
|
|
*/
|
|
async function instrumentUploads(page: Page, delayMs: number) {
|
|
let active = 0;
|
|
let peak = 0;
|
|
await page.route('**/api/v1/upload', async (route) => {
|
|
active++;
|
|
peak = Math.max(peak, active);
|
|
await new Promise((r) => setTimeout(r, delayMs));
|
|
active--;
|
|
await route.continue();
|
|
});
|
|
return { peak: () => peak };
|
|
}
|
|
|
|
async function warmUploadChunk(page: Page) {
|
|
// Load the code-split /upload route while online so a later reload/navigation
|
|
// doesn't fetch the chunk at a bad moment (mirrors offline-resume.spec).
|
|
await page.goto('/upload');
|
|
await page.goto('/feed');
|
|
}
|
|
|
|
test.describe('Upload — client queue under a burst', () => {
|
|
test('a 16-file burst drains serially, persists to IndexedDB, and all land', async ({
|
|
page,
|
|
guest,
|
|
signIn,
|
|
db,
|
|
browserName,
|
|
}) => {
|
|
skipIfNoIdbBlobs(browserName);
|
|
const g = await guest('BurstSerial');
|
|
await signIn(page, g);
|
|
await warmUploadChunk(page);
|
|
const uploads = await instrumentUploads(page, 200);
|
|
|
|
// Stage the whole burst through the real UI.
|
|
const { dir, paths } = makeBurstFiles();
|
|
try {
|
|
const feed = new FeedPage(page);
|
|
const sheet = new UploadSheet(page);
|
|
await feed.openUploadSheet();
|
|
await sheet.stageFiles(paths);
|
|
await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
|
|
await sheet.fillCaption('burst of 16 #hochzeit');
|
|
await sheet.submit();
|
|
await page.waitForURL('**/feed', { timeout: 15_000 });
|
|
|
|
// (2) PERSISTENCE: all 16 are written to IndexedDB with blobs essentially
|
|
// immediately — before most have been sent. Catch the burst while the
|
|
// queue still holds most of them.
|
|
await expect
|
|
.poll(async () => (await queueRows(page)).length, { timeout: 10_000 })
|
|
.toBe(BURST);
|
|
|
|
// While draining, pending rows keep their blob; done rows have dropped it.
|
|
// Poll for a mid-burst moment that shows both invariants at once.
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const rows = await queueRows(page);
|
|
const pendingKeepBlob = rows
|
|
.filter((r) => r.status === 'pending' || r.status === 'uploading')
|
|
.every((r) => r.hasBlob);
|
|
const doneDropBlob = rows.filter((r) => r.status === 'done').every((r) => !r.hasBlob);
|
|
return pendingKeepBlob && doneDropBlob;
|
|
},
|
|
{ timeout: 10_000 }
|
|
)
|
|
.toBe(true);
|
|
|
|
// (3) ALL LAND: every file is created server-side.
|
|
await expect.poll(() => db.countUploadsForUser(g.userId), { timeout: 30_000 }).toBe(BURST);
|
|
|
|
// (1) SERIAL: never more than one upload in flight at a time.
|
|
expect(uploads.peak()).toBe(1);
|
|
|
|
// Clean end — the queue settles all items to done (no stuck error/blocked).
|
|
await expect
|
|
.poll(async () => (await queueRows(page)).every((r) => r.status === 'done'), {
|
|
timeout: 10_000,
|
|
})
|
|
.toBe(true);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('a hard reload mid-burst resumes the remaining uploads — nothing lost', async ({
|
|
page,
|
|
guest,
|
|
signIn,
|
|
db,
|
|
browserName,
|
|
}) => {
|
|
skipIfNoIdbBlobs(browserName);
|
|
const g = await guest('BurstResume');
|
|
await signIn(page, g);
|
|
await warmUploadChunk(page);
|
|
// Bigger delay → a wide, reliable mid-burst window to reload inside. (We don't
|
|
// assert seriality here — test 1 already proves it, and a reload aborts the
|
|
// in-flight request mid-intercept, which would overcount concurrency.)
|
|
await instrumentUploads(page, 300);
|
|
|
|
const { dir, paths } = makeBurstFiles();
|
|
try {
|
|
const feed = new FeedPage(page);
|
|
const sheet = new UploadSheet(page);
|
|
await feed.openUploadSheet();
|
|
await sheet.stageFiles(paths);
|
|
await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
|
|
await sheet.fillCaption('burst then reload');
|
|
await sheet.submit();
|
|
await page.waitForURL('**/feed', { timeout: 15_000 });
|
|
|
|
// Wait until we're genuinely mid-burst: a few landed, but far from all.
|
|
await expect
|
|
.poll(() => db.countUploadsForUser(g.userId), { timeout: 20_000 })
|
|
.toBeGreaterThanOrEqual(3);
|
|
const landedBeforeReload = await db.countUploadsForUser(g.userId);
|
|
expect(landedBeforeReload).toBeLessThan(BURST);
|
|
|
|
// Hard reload — wipes the JS module (and its in-flight drain), exactly like
|
|
// a closed tab / killed PWA. The remaining pending items live only in
|
|
// IndexedDB now.
|
|
await page.reload();
|
|
// Deliberately NOT navigating to /upload. Rehydration is now module-level and
|
|
// auth-gated (upload-queue.ts `hydrateQueue`), so the queue resumes wherever the
|
|
// reload lands. This assertion is the regression guard for the defect it replaced:
|
|
// `loadQueue()` used to have a single call site in the whole app — the /upload
|
|
// route's onMount — so a guest who reloaded anywhere else saw a 0 badge and their
|
|
// staged photos never left the phone, having already been shown a success.
|
|
|
|
// (4) RESUME: every file ends up server-side without re-staging anything.
|
|
// `>=` not `===`: the only imperfection possible is a DUPLICATE (an upload
|
|
// that succeeded server-side in the ~1ms between the XHR load event and the
|
|
// IndexedDB 'done' write, caught by the reload, then re-sent on resume).
|
|
// That's at worst one extra row, never a lost photo — the property we care
|
|
// about. A shortfall (< BURST) would be a real data-loss regression.
|
|
await expect
|
|
.poll(() => db.countUploadsForUser(g.userId), { timeout: 30_000 })
|
|
.toBeGreaterThanOrEqual(BURST);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|