mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add DM test suites and headless e2e CI
This commit is contained in:
1 parent
b4bdb21424
commit
89e82778d6
5 files changed
+391
-63
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user