diff --git a/AGENTS.md b/AGENTS.md index d1e1ae70..d2b590e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,11 @@ deviating from agreed scope or product behavior. Target **9/10+ for minimalness, elegance, and correctness**: the smallest complete solution, clear ownership, and no known material defects. Prefer existing patterns and subtraction. No opportunistic refactors, speculative abstractions, or new -features disguised as fixes. If the fix keeps growing, revisit the cause and scope. +features disguised as fixes. Before expanding into another shared subsystem or +adding alternate-adapter support, show the human the scope change and smallest +complete alternative. Require a current caller or explicit approval for adapter +parity. Review necessity separately from correctness; passing tests do not justify +scope growth. Split at real ownership boundaries, not by deleting safety coverage. Keep files cohesive and group modules and tests by owner. Treat size as a review signal, not a quota. Extract stable boundaries only when they simplify the diff --git a/dev/agent-observer.test.mjs b/dev/agent-observer.test.mjs index b113e130..46aef126 100644 --- a/dev/agent-observer.test.mjs +++ b/dev/agent-observer.test.mjs @@ -1,4 +1,4 @@ -import { test, expect } from "vitest"; +import { test, expect, vi, beforeEach, afterEach } from "vitest"; import { finalizeEvent, generateSecretKey, @@ -7,6 +7,12 @@ import { } from "nostr-tools"; import { decodeAgentObserver } from "./agent-observer.mjs"; +// Keep frame construction and validation in the same second at the ±300s boundary. +beforeEach(() => { + vi.spyOn(Date, "now").mockReturnValue(1700000000999); +}); +afterEach(() => vi.restoreAllMocks()); + const owner = generateSecretKey(), agent = generateSecretKey(), stranger = generateSecretKey(); diff --git a/docs/browser-testing.md b/docs/browser-testing.md index ef93fb08..6b6975a2 100644 --- a/docs/browser-testing.md +++ b/docs/browser-testing.md @@ -344,3 +344,13 @@ focused visible dwell, non-reading opening/composer focus, individual markers, encrypted publication/readback, reload, cancellation and local manual-unread. The reload control holds network content so verified disk-restore wiring is required. This is not a deployed-relay, native signer or cross-device integration test. + +## Fixture server isolation + +Concurrent Vite fixture servers must own separate optimizer caches. Use +`tests/browser/vite-server.mjs` for new fixtures; its `close()` releases the owned +cache. The existing emoji and conversation fixtures retain their explicitly owned +temporary caches. Do not share Vite's default `node_modules/.vite`: another server +can invalidate dependency imports and leave a blank fixture with a 504 +`Outdated Optimize Dep`. Keep import failures visible; retries or longer UI waits +do not repair module loading. diff --git a/tests/browser/agents.spec.mjs b/tests/browser/agents.spec.mjs index f802408a..7c69a256 100644 --- a/tests/browser/agents.spec.mjs +++ b/tests/browser/agents.spec.mjs @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { createServer } from "vite"; +import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; @@ -16,8 +16,8 @@ test("My agents reads the existing library with exact linked keys and session-sa }); const errors = []; page.on("pageerror", (error) => errors.push(String(error))); - await server.listen(); try { + await server.listen(); await page.goto( `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/agents.html`, ); diff --git a/tests/browser/avatar-loading.spec.mjs b/tests/browser/avatar-loading.spec.mjs index f016a10a..b9aac32b 100644 --- a/tests/browser/avatar-loading.spec.mjs +++ b/tests/browser/avatar-loading.spec.mjs @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { createServer } from "vite"; +import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; @@ -14,8 +14,8 @@ test("shared avatars defer offscreen artwork, omit the referrer and recover from logLevel: "error", server: { host: "127.0.0.1", port: 0, strictPort: false }, }); - await server.listen(); try { + await server.listen(); const requests = []; await page.route("https://images.example/avatar.png", async (route) => { requests.push(route.request().headers()); diff --git a/tests/browser/image-scroll.spec.mjs b/tests/browser/image-scroll.spec.mjs index a0b56491..cdf724df 100644 --- a/tests/browser/image-scroll.spec.mjs +++ b/tests/browser/image-scroll.spec.mjs @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { createServer } from "vite"; +import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; import { settle, anchor, expectAnchor } from "./timeline.mjs"; @@ -54,7 +54,6 @@ async function fixtureServer() { test("delayed and failed images preserve bottom and reading anchors across remounts", async ({ page, }, testInfo) => { - const server = await fixtureServer(); const pending = new Set(); const requests = new Map(); let held = true; @@ -102,8 +101,9 @@ test("delayed and failed images preserve bottom and reading anchors across remou ), ) .toBe(true); - await server.listen(); + const server = await fixtureServer(); try { + await server.listen(); await page.goto( `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/image-scroll.html`, ); @@ -180,9 +180,12 @@ test("delayed and failed images preserve bottom and reading anchors across remou expect(box.height).toBeGreaterThan(0); } } finally { - await release(); - await page.unrouteAll({ behavior: "wait" }); - await server.close(); + try { + await release(); + await page.unrouteAll({ behavior: "wait" }); + } finally { + await server.close(); + } } }); @@ -314,12 +317,12 @@ async function routeOriginals(page, requests) { test("blurhash visibility, decode swap, failure and retired source lifetimes", async ({ page, }, testInfo) => { - const server = await fixtureServer(); const requests = []; await holdDecodes(page); await routeOriginals(page, requests); - await server.listen(); + const server = await fixtureServer(); try { + await server.listen(); await page.goto( `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/attachment-image.html`, ); @@ -402,19 +405,22 @@ test("blurhash visibility, decode swap, failure and retired source lifetimes", a contentType: "application/json", }); } finally { - await page.unrouteAll({ behavior: "wait" }); - await server.close(); + try { + await page.unrouteAll({ behavior: "wait" }); + } finally { + await server.close(); + } } }); test("original ready first cannot regress on late visibility; missing and invalid hashes still load", async ({ page, }) => { - const server = await fixtureServer(); await holdDecodes(page, true); await routeOriginals(page, []); - await server.listen(); + const server = await fixtureServer(); try { + await server.listen(); await page.goto( `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/attachment-image.html`, ); @@ -433,19 +439,22 @@ test("original ready first cannot regress on late visibility; missing and invali await shown(page); } } finally { - await page.unrouteAll({ behavior: "wait" }); - await server.close(); + try { + await page.unrouteAll({ behavior: "wait" }); + } finally { + await server.close(); + } } }); test("original decode rejection retains blur and the next source still recovers", async ({ page, }) => { - const server = await fixtureServer(); await holdDecodes(page); await routeOriginals(page, []); - await server.listen(); + const server = await fixtureServer(); try { + await server.listen(); await page.goto( `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/attachment-image.html`, ); @@ -463,8 +472,11 @@ test("original decode rejection retains blur and the next source still recovers" await shown(page); await expect(frame(page).locator("canvas")).toHaveCount(0); } finally { - await page.unrouteAll({ behavior: "wait" }); - await server.close(); + try { + await page.unrouteAll({ behavior: "wait" }); + } finally { + await server.close(); + } } }); @@ -472,7 +484,6 @@ for (const unavailable of ["canvas", "visibility"]) { test(`unavailable ${unavailable} keeps the placeholder and original loading`, async ({ page, }) => { - const server = await fixtureServer(); await holdDecodes(page); await page.addInitScript((unavailable) => { if (unavailable === "canvas") @@ -480,8 +491,9 @@ for (const unavailable of ["canvas", "visibility"]) { else window.IntersectionObserver = undefined; }, unavailable); await routeOriginals(page, []); - await server.listen(); + const server = await fixtureServer(); try { + await server.listen(); await page.goto( `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/attachment-image.html`, ); @@ -496,8 +508,11 @@ for (const unavailable of ["canvas", "visibility"]) { await shown(page); await expect(frame(page).locator("canvas")).toHaveCount(0); } finally { - await page.unrouteAll({ behavior: "wait" }); - await server.close(); + try { + await page.unrouteAll({ behavior: "wait" }); + } finally { + await server.close(); + } } }); } diff --git a/tests/browser/mention-edit.spec.mjs b/tests/browser/mention-edit.spec.mjs index 315205ce..c42fee27 100644 --- a/tests/browser/mention-edit.spec.mjs +++ b/tests/browser/mention-edit.spec.mjs @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { createServer } from "vite"; +import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; test("editing selected name plus pasting same name cannot transfer notification to pasted prose", async ({ @@ -13,8 +13,8 @@ test("editing selected name plus pasting same name cannot transfer notification logLevel: "error", server: { host: "127.0.0.1", port: 0 }, }); - await server.listen(); try { + await server.listen(); await page.goto( `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/mentions.html`, ); diff --git a/tests/browser/mentions.spec.mjs b/tests/browser/mentions.spec.mjs index 5115d136..fe7cbe1f 100644 --- a/tests/browser/mentions.spec.mjs +++ b/tests/browser/mentions.spec.mjs @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { createServer } from "vite"; +import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; @@ -16,8 +16,8 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta }); const errors = []; page.on("pageerror", (error) => errors.push(String(error))); - await server.listen(); try { + await server.listen(); await page.goto( `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/mentions.html`, ); diff --git a/tests/browser/messages.spec.mjs b/tests/browser/messages.spec.mjs index 99b1c3d1..f95a7aec 100644 --- a/tests/browser/messages.spec.mjs +++ b/tests/browser/messages.spec.mjs @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { createServer } from "vite"; +import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; @@ -18,8 +18,8 @@ test("shared thread UI auto-loads, follows live replies, retries and isolates re }); const errors = []; page.on("pageerror", (error) => errors.push(String(error))); - await server.listen(); try { + await server.listen(); const address = server.httpServer.address(); await page.goto( `http://127.0.0.1:${address.port}/tests/fixtures/messages.html`, diff --git a/tests/browser/profiles.spec.mjs b/tests/browser/profiles.spec.mjs index 8a9ea388..e300d21e 100644 --- a/tests/browser/profiles.spec.mjs +++ b/tests/browser/profiles.spec.mjs @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { createServer } from "vite"; +import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; @@ -16,8 +16,8 @@ test("profile plumbing: exact avatar/mention targets, thread enrichment, lifecyc }); const errors = []; page.on("pageerror", (error) => errors.push(String(error))); - await server.listen(); try { + await server.listen(); await page.goto( `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/profiles.html`, ); @@ -170,8 +170,8 @@ test("contextual panel callbacks retire with opening, channel, contribution and logLevel: "error", server: { host: "127.0.0.1", port: 0, strictPort: false }, }); - await server.listen(); try { + await server.listen(); await page.goto( `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/profiles.html?context-probe`, ); diff --git a/tests/browser/terminal-renderer.spec.mjs b/tests/browser/terminal-renderer.spec.mjs index f78d8b9f..db59007a 100644 --- a/tests/browser/terminal-renderer.spec.mjs +++ b/tests/browser/terminal-renderer.spec.mjs @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { createServer } from "vite"; +import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; diff --git a/tests/browser/typeahead.spec.mjs b/tests/browser/typeahead.spec.mjs index d0107a59..2bb5651a 100644 --- a/tests/browser/typeahead.spec.mjs +++ b/tests/browser/typeahead.spec.mjs @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { createServer } from "vite"; +import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; diff --git a/tests/browser/vite-server.mjs b/tests/browser/vite-server.mjs new file mode 100644 index 00000000..48a44872 --- /dev/null +++ b/tests/browser/vite-server.mjs @@ -0,0 +1,24 @@ +import { createServer as createViteServer } from "vite"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Parallel fixture servers must not invalidate each other's optimized imports. +export async function createServer(config) { + const cacheDir = await mkdtemp(join(tmpdir(), "buzz-fixture-vite-")); + try { + const server = await createViteServer({ ...config, cacheDir }); + const close = server.close.bind(server); + server.close = async () => { + try { + await close(); + } finally { + await rm(cacheDir, { recursive: true, force: true }); + } + }; + return server; + } catch (error) { + await rm(cacheDir, { recursive: true, force: true }); + throw error; + } +} diff --git a/tests/integration/vite-fixture.test.mjs b/tests/integration/vite-fixture.test.mjs new file mode 100644 index 00000000..0dac1ce0 --- /dev/null +++ b/tests/integration/vite-fixture.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { createServer } from "../browser/vite-server.mjs"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const config = { + root, + configFile: false, + envDir: false, + logLevel: "silent", + server: { middlewareMode: true, watch: null }, + optimizeDeps: { noDiscovery: true, include: [] }, +}; + +test("concurrent fixture servers own distinct optimizer caches and retire only their own files", async () => { + const servers = []; + try { + servers.push(await createServer(config)); + servers.push(await createServer(config)); + const [first, second] = servers; + assert.notEqual(first.config.cacheDir, second.config.cacheDir); + assert.notEqual(first.config.cacheDir, join(root, "node_modules/.vite")); + const retained = join(second.config.cacheDir, "retained-dependency.js"); + await writeFile(retained, "export default 1;"); + await first.close(); + await assert.rejects(stat(first.config.cacheDir), { code: "ENOENT" }); + assert.equal(await readFile(retained, "utf8"), "export default 1;"); + await second.close(); + await assert.rejects(stat(second.config.cacheDir), { code: "ENOENT" }); + } finally { + await Promise.all(servers.map((server) => server.close())); + } +}); + +test("failed fixture creation cleans its owned cache and preserves the error", async () => { + let cacheDir; + const failure = new Error("fixture configuration failed"); + await assert.rejects( + createServer({ + ...config, + plugins: [ + { + name: "fail-fixture-configuration", + configResolved(resolved) { + cacheDir = resolved.cacheDir; + throw failure; + }, + }, + ], + }), + (error) => error === failure, + ); + assert.ok(cacheDir); + await assert.rejects(stat(cacheDir), { code: "ENOENT" }); +}); + +test("browser fixture call sites cannot fall back to Vite's shared default cache", async () => { + const browser = join(root, "tests/browser"); + for (const name of await readdir(browser)) { + if (!name.endsWith(".spec.mjs")) continue; + const source = await readFile(join(browser, name), "utf8"); + if (!source.includes('import { createServer } from "vite"')) continue; + // These existing tests own a temporary directory through their full lifecycle. + assert.ok(["emoji.spec.mjs", "conversation.spec.mjs"].includes(name), name); + assert.match( + source, + /cacheDir[,:]/, + `${name} must keep its explicit cache`, + ); + } +});