Add DM test suites and headless e2e CI

This commit is contained in:
thePR0M3TH3AN committed 2026-01-19 13:43:13 -05:00
1 parent b4bdb21424
commit 89e82778d6
5 files changed
+391 -63

No files matched your search

+120
View File
@@ -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
+7 -1
View File
@@ -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
+3
View File
@@ -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"
},
+57
View File
@@ -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 <file...>");
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");
+204 -62
View File
@@ -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();
}
});