Merge branch 'test/burst-queue'
Add an e2e regression covering the client upload queue under a 10-20 photo burst: serial drain, IndexedDB persistence, all-land, and reload-resume.
This commit is contained in:
214
e2e/specs/02-upload/burst-queue.spec.ts
Normal file
214
e2e/specs/02-upload/burst-queue.spec.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* 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 { 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,
|
||||||
|
}) => {
|
||||||
|
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,
|
||||||
|
}) => {
|
||||||
|
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();
|
||||||
|
// The queue only resumes where loadQueue() runs — the /upload route's
|
||||||
|
// onMount. Navigating there is the "reopen the composer" recovery path.
|
||||||
|
await page.goto('/upload');
|
||||||
|
|
||||||
|
// (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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user