diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47b92474..35ae9828 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,3 +76,123 @@ jobs: - name: Fail if visual regressions detected if: steps.visual.outcome == 'failure' run: exit 1 + + dm-unit-tests: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Cache npm directory + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ matrix.node-version }}-npm-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.node-version }}-npm- + + - name: Cache node_modules + id: node-modules-cache + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-${{ matrix.node-version }}-modules-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.node-version }}-modules- + + - name: Install dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: npm ci + + - name: Run DM unit tests + run: npm run test:dm:unit + + dm-integration-tests: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Cache npm directory + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ matrix.node-version }}-npm-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.node-version }}-npm- + + - name: Cache node_modules + id: node-modules-cache + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-${{ matrix.node-version }}-modules-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.node-version }}-modules- + + - name: Install dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: npm ci + + - name: Run DM integration tests + run: npm run test:dm:integration + + e2e-headless: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Cache npm directory + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ matrix.node-version }}-npm-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.node-version }}-npm- + + - name: Cache node_modules + id: node-modules-cache + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-${{ matrix.node-version }}-modules-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.node-version }}-modules- + + - name: Install dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run headless E2E tests + run: npm run test:e2e diff --git a/README.md b/README.md index 5de0cf26..109cf05d 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,9 @@ http://localhost:8000 ### Common Commands - **Run unit tests**: `npm run test:unit` +- **Run DM unit tests**: `npm run test:dm:unit` +- **Run DM integration tests**: `npm run test:dm:integration` +- **Run headless E2E tests**: `npm run test:e2e` - **Format code**: `npm run format` - **Lint code**: `npm run lint` @@ -393,10 +396,13 @@ placeholder at “—” and development builds log a warning—so mixed deploym ## Testing -Continuous integration runs CSS linting/builds, the Playwright kitchen-sink snapshots, and the Node-based unit tests on every push. +Continuous integration runs CSS linting/builds, the DM unit/integration suites, the Playwright kitchen-sink snapshots, headless E2E flows, and the Node-based unit tests on every push. Before pushing, run `npm run build` locally so the Tailwind bundle regenerates. Pair that with `npm run test:unit` for application logic changes and `npm run test:visual` for presentation updates to mirror the CI surface area. +For DM-specific changes, also run `npm run test:dm:unit` and +`npm run test:dm:integration` to cover the direct message flows. +Use `npm run test:e2e` to execute the headless Playwright journeys in `tests/e2e`. ### Manual QA checklist diff --git a/package.json b/package.json index f17b8aee..306969b4 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "check:css": "npm run build:css", "test:visual": "npm run build && npx playwright test", "test:visual:update": "npm run build && cross-env UPDATE_VISUAL_BASELINES=1 npx playwright test", + "test:e2e": "npm run build && npx playwright test tests/e2e", + "test:dm:unit": "node scripts/run-targeted-tests.mjs tests/dm-db.test.mjs tests/dm-decryptor.test.mjs tests/dm-normalization.test.mjs tests/nostr-send-direct-message.test.mjs", + "test:dm:integration": "node scripts/run-targeted-tests.mjs tests/nostr/dm-direct-message-flow.test.mjs", "test:unit": "node scripts/run-unit-tests.mjs", "telemetry:aggregate": "node scripts/telemetry-aggregator.mjs" }, diff --git a/scripts/run-targeted-tests.mjs b/scripts/run-targeted-tests.mjs new file mode 100644 index 00000000..9f059ff2 --- /dev/null +++ b/scripts/run-targeted-tests.mjs @@ -0,0 +1,57 @@ +import { spawn } from "node:child_process"; +import { access } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const rootDir = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url))); +const setupImport = new URL("../tests/test-helpers/setup-localstorage.mjs", import.meta.url); +const requestedFiles = process.argv.slice(2); + +if (!requestedFiles.length) { + console.error("No test files provided. Usage: node scripts/run-targeted-tests.mjs "); + process.exit(1); +} + +const testFiles = requestedFiles.map((file) => + path.isAbsolute(file) ? file : path.join(rootDir, file), +); + +for (const testFile of testFiles) { + try { + await access(testFile); + } catch (error) { + console.error(`Test file not found: ${testFile}`); + throw error; + } +} + +for (const testFile of testFiles) { + const relativePath = path.relative(rootDir, testFile); + console.log(`\n→ Running ${relativePath}`); + + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["--import", setupImport.href, testFile], { + stdio: "inherit", + }); + + child.on("close", (code, signal) => { + if (signal) { + reject(new Error(`${relativePath} exited with signal ${signal}`)); + return; + } + + if (code !== 0) { + reject(new Error(`${relativePath} failed with exit code ${code}`)); + return; + } + + resolve(); + }); + + child.on("error", (error) => { + reject(error); + }); + }); +} + +console.log("\n✔ Targeted tests passed"); diff --git a/tests/nostr/dm-direct-message-flow.test.mjs b/tests/nostr/dm-direct-message-flow.test.mjs index d6520251..40c99d09 100644 --- a/tests/nostr/dm-direct-message-flow.test.mjs +++ b/tests/nostr/dm-direct-message-flow.test.mjs @@ -90,61 +90,222 @@ class FakeRelayPool { } } -test("two clients can send, decrypt, notify, and mark DMs as seen", async () => { +async function configureNostrTools() { const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; const previousNostrTools = globalThis.NostrTools; const previousReady = globalThis.nostrToolsReady; const nostrTools = await import("nostr-tools"); + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = nostrTools; + globalThis.NostrTools = nostrTools; + globalThis.nostrToolsReady = Promise.resolve({ ok: true, value: nostrTools }); + + const restore = () => { + if (previousCanonical === undefined) { + delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; + } else { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical; + } + + if (previousNostrTools === undefined) { + delete globalThis.NostrTools; + } else { + globalThis.NostrTools = previousNostrTools; + } + + if (previousReady === undefined) { + delete globalThis.nostrToolsReady; + } else { + globalThis.nostrToolsReady = previousReady; + } + }; + + return { nostrTools, restore }; +} + +async function setupDmScenario() { + const { nostrTools, restore } = await configureNostrTools(); + const senderSecret = nostrTools.generateSecretKey(); + const receiverSecret = nostrTools.generateSecretKey(); + const senderPrivateKey = nostrTools.utils.bytesToHex(senderSecret); + const receiverPrivateKey = nostrTools.utils.bytesToHex(receiverSecret); + + const senderPubkey = nostrTools.getPublicKey(senderPrivateKey); + const receiverPubkey = nostrTools.getPublicKey(receiverPrivateKey); + + const createSigner = (privateKey, pubkey) => ({ + type: "test", + pubkey, + signEvent: (event) => { + const finalized = nostrTools.finalizeEvent(event, privateKey); + return { ...event, id: finalized.id, sig: finalized.sig }; + }, + nip04Encrypt: (target, plaintext) => + nostrTools.nip04.encrypt(privateKey, target, plaintext), + nip04Decrypt: (target, ciphertext) => + nostrTools.nip04.decrypt(privateKey, target, ciphertext), + }); + + const senderSigner = createSigner(senderPrivateKey, senderPubkey); + const receiverSigner = createSigner(receiverPrivateKey, receiverPubkey); + + const relayPool = new FakeRelayPool(); + + const senderClient = new NostrClient(); + senderClient.pool = relayPool; + senderClient.relays = ["wss://relay.unit.test"]; + senderClient.readRelays = []; + senderClient.writeRelays = []; + senderClient.pubkey = senderPubkey; + + const receiverClient = new NostrClient(); + receiverClient.pool = relayPool; + receiverClient.relays = ["wss://relay.unit.test"]; + receiverClient.readRelays = []; + receiverClient.writeRelays = []; + receiverClient.pubkey = receiverPubkey; + + const receiverService = new NostrService({ logger: () => {} }); + receiverService.nostrClient = receiverClient; + + return { + nostrTools, + restore, + senderSigner, + receiverSigner, + senderPubkey, + receiverPubkey, + relayPool, + senderClient, + receiverClient, + receiverService, + }; +} + +test("DM relay duplication is deduped", async () => { + const { + nostrTools, + restore, + senderSigner, + receiverSigner, + receiverPubkey, + relayPool, + senderClient, + receiverClient, + receiverService, + } = await setupDmScenario(); + try { - globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = nostrTools; - globalThis.NostrTools = nostrTools; - globalThis.nostrToolsReady = Promise.resolve({ ok: true, value: nostrTools }); - - const senderSecret = nostrTools.generateSecretKey(); - const receiverSecret = nostrTools.generateSecretKey(); - const senderPrivateKey = nostrTools.utils.bytesToHex(senderSecret); - const receiverPrivateKey = nostrTools.utils.bytesToHex(receiverSecret); - - const senderPubkey = nostrTools.getPublicKey(senderPrivateKey); - const receiverPubkey = nostrTools.getPublicKey(receiverPrivateKey); - - const createSigner = (privateKey, pubkey) => ({ - type: "test", - pubkey, - signEvent: (event) => { - const finalized = nostrTools.finalizeEvent(event, privateKey); - return { ...event, id: finalized.id, sig: finalized.sig }; - }, - nip04Encrypt: (target, plaintext) => - nostrTools.nip04.encrypt(privateKey, target, plaintext), - nip04Decrypt: (target, ciphertext) => - nostrTools.nip04.decrypt(privateKey, target, ciphertext), + const notifications = []; + receiverService.on("directMessages:notification", (payload) => { + notifications.push(payload); }); - const senderSigner = createSigner(senderPrivateKey, senderPubkey); - const receiverSigner = createSigner(receiverPrivateKey, receiverPubkey); + const received = []; + const subscription = receiverClient.subscribeDirectMessages(receiverPubkey, { + onMessage: (message, { event }) => { + received.push(message); + receiverService.applyDirectMessage( + { ...message, actorPubkey: receiverPubkey }, + { reason: "subscription", event }, + ); + }, + }); - const relayPool = new FakeRelayPool(); + setActiveSigner(senderSigner); + const sendResult = await senderClient.sendDirectMessage( + nostrTools.nip19.npubEncode(receiverPubkey), + "Hello from sender", + ); - const senderClient = new NostrClient(); - senderClient.pool = relayPool; - senderClient.relays = ["wss://relay.unit.test"]; - senderClient.readRelays = []; - senderClient.writeRelays = []; - senderClient.pubkey = senderPubkey; + assert.equal(sendResult.ok, true); - const receiverClient = new NostrClient(); - receiverClient.pool = relayPool; - receiverClient.relays = ["wss://relay.unit.test"]; - receiverClient.readRelays = []; - receiverClient.writeRelays = []; - receiverClient.pubkey = receiverPubkey; + setActiveSigner(receiverSigner); + relayPool.flushQueue({ duplicate: true }); - const receiverService = new NostrService({ logger: () => {} }); - receiverService.nostrClient = receiverClient; + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(received.length, 1, "duplicate relay events should be deduped"); + assert.equal(notifications.length, 1, "incoming messages should notify once"); + + subscription.unsub(); + } finally { + restore(); + } +}); + +test("out-of-order DM delivery keeps newest messages first", async () => { + const { + nostrTools, + restore, + senderSigner, + receiverSigner, + receiverPubkey, + relayPool, + senderClient, + receiverClient, + receiverService, + } = await setupDmScenario(); + + try { + setActiveSigner(receiverSigner); + receiverClient.subscribeDirectMessages(receiverPubkey, { + onMessage: (message, { event }) => { + receiverService.applyDirectMessage( + { ...message, actorPubkey: receiverPubkey }, + { reason: "subscription", event }, + ); + }, + }); + + setActiveSigner(senderSigner); + const firstSend = await senderClient.sendDirectMessage( + nostrTools.nip19.npubEncode(receiverPubkey), + "First message", + ); + const secondSend = await senderClient.sendDirectMessage( + nostrTools.nip19.npubEncode(receiverPubkey), + "Second message", + ); + + assert.equal(firstSend.ok, true); + assert.equal(secondSend.ok, true); + + relayPool.queue[0].created_at = 100; + relayPool.queue[1].created_at = 200; + + setActiveSigner(receiverSigner); + relayPool.deliver(relayPool.queue[1]); + relayPool.deliver(relayPool.queue[0]); + relayPool.queue = []; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const messages = receiverService.getDirectMessages(); + assert.equal(messages.length, 2, "two DM messages should be present"); + assert.equal(messages[0].plaintext, "Second message"); + assert.equal(messages[1].plaintext, "First message"); + } finally { + restore(); + } +}); + +test("DM reconnect replays do not duplicate messages or reset seen state", async () => { + const { + nostrTools, + restore, + senderSigner, + receiverSigner, + senderPubkey, + receiverPubkey, + relayPool, + senderClient, + receiverClient, + receiverService, + } = await setupDmScenario(); + + try { const notifications = []; receiverService.on("directMessages:notification", (payload) => { notifications.push(payload); @@ -176,13 +337,10 @@ test("two clients can send, decrypt, notify, and mark DMs as seen", async () => assert.equal(sendResult.ok, true); setActiveSigner(receiverSigner); - relayPool.flushQueue({ duplicate: true }); + relayPool.flushQueue(); await new Promise((resolve) => setTimeout(resolve, 10)); - assert.equal(received.length, 1, "duplicate relay events should be deduped"); - assert.equal(notifications.length, 1, "incoming messages should notify once"); - const conversationId = `dm:${[senderPubkey, receiverPubkey].sort().join(":")}`; assert.equal(receiverService.getDirectMessageUnseenCount(conversationId), 1); @@ -215,22 +373,6 @@ test("two clients can send, decrypt, notify, and mark DMs as seen", async () => ); assert.equal(notifications.length, 1, "replays should not trigger notifications"); } finally { - if (previousCanonical === undefined) { - delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; - } else { - globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical; - } - - if (previousNostrTools === undefined) { - delete globalThis.NostrTools; - } else { - globalThis.NostrTools = previousNostrTools; - } - - if (previousReady === undefined) { - delete globalThis.nostrToolsReady; - } else { - globalThis.nostrToolsReady = previousReady; - } + restore(); } });