diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35ae9828..2de7e81d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,13 +70,53 @@ jobs: test-results if-no-files-found: ignore - - name: Run unit tests - run: npm run test:unit - - name: Fail if visual regressions detected if: steps.visual.outcome == 'failure' run: exit 1 + unit-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [20] + shard: [1, 2, 3] + + 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 unit tests (shard ${{ matrix.shard }}/3) + env: + UNIT_TEST_TIMEOUT_MS: 300000 + run: npm run test:unit:shard${{ matrix.shard }} + dm-unit-tests: runs-on: ubuntu-latest strategy: diff --git a/README.md b/README.md index 9ca262ff..32fd8737 100644 --- a/README.md +++ b/README.md @@ -387,7 +387,7 @@ placeholder at “—” and development builds log a warning—so mixed deploym 4. **Make Changes**: - Ensure your code follows best practices and is well-documented. 5. **Test**: - - Run unit tests: `npm run test:unit` + - Run unit tests: `npm run test:unit` (or a shard like `npm run test:unit:shard1`) - Validate the site functionality locally before submitting. 6. **Submit a Pull Request**: - Explain your changes and reference any related issues. @@ -405,8 +405,12 @@ placeholder at “—” and development builds log a warning—so mixed deploym 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. +Pair that with `npm run test:unit` for application logic changes, or a shard +(`npm run test:unit:shard1`, `test:unit:shard2`, `test:unit:shard3`) when you want to +split the suite. You can also set `UNIT_TEST_SHARD=1/3` or pass `--shard=1/3` +to `scripts/run-unit-tests.mjs` for custom shard splits, and use +`UNIT_TEST_TIMEOUT_MS=120000` to abort a single stalled test file without +blocking the entire run. `npm run test:visual` covers 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`. diff --git a/package.json b/package.json index d87d02d3..ada1e56d 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ "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", + "test:unit:shard1": "cross-env UNIT_TEST_SHARD=1/3 node scripts/run-unit-tests.mjs", + "test:unit:shard2": "cross-env UNIT_TEST_SHARD=2/3 node scripts/run-unit-tests.mjs", + "test:unit:shard3": "cross-env UNIT_TEST_SHARD=3/3 node scripts/run-unit-tests.mjs", "telemetry:aggregate": "node scripts/telemetry-aggregator.mjs" }, "devDependencies": { diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index e131fd94..f22ad3ae 100755 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -7,6 +7,17 @@ const rootDir = path.dirname(fileURLToPath(new URL("../package.json", import.met const testsDir = path.join(rootDir, "tests"); const testFiles = []; const setupImport = new URL("../tests/test-helpers/setup-localstorage.mjs", import.meta.url); +const shardArg = process.argv.find((arg) => arg.startsWith("--shard=")); +const shardValue = shardArg ? shardArg.split("=")[1] : process.env.UNIT_TEST_SHARD; +const timeoutMs = process.env.UNIT_TEST_TIMEOUT_MS + ? Number(process.env.UNIT_TEST_TIMEOUT_MS) + : null; + +if (timeoutMs !== null && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) { + throw new Error( + "UNIT_TEST_TIMEOUT_MS must be a positive number representing milliseconds.", + ); +} async function collectTests(dir) { const entries = await readdir(dir, { withFileTypes: true }); @@ -40,42 +51,120 @@ async function collectTests(dir) { } } -await collectTests(testsDir); +function parseShard(value) { + if (!value) { + return null; + } -testFiles.sort((a, b) => a.localeCompare(b)); + const match = /^(\d+)\/(\d+)$/.exec(value.trim()); + if (!match) { + throw new Error("Shard format must be /, e.g. 1/3."); + } -if (testFiles.length === 0) { - console.log("No unit tests found."); - process.exit(0); + const index = Number(match[1]); + const total = Number(match[2]); + + if (!Number.isInteger(index) || !Number.isInteger(total) || total < 1) { + throw new Error("Shard values must be positive integers."); + } + + if (index < 1 || index > total) { + throw new Error("Shard index must be between 1 and total (inclusive)."); + } + + return { index, total }; } -for (const testFile of testFiles) { - const relativePath = path.relative(rootDir, testFile); - console.log(`\n→ Running ${relativePath}`); +async function run() { + await collectTests(testsDir); - await new Promise((resolve, reject) => { - const child = spawn(process.execPath, ["--import", setupImport.href, testFile], { - stdio: "inherit", - }); + testFiles.sort((a, b) => a.localeCompare(b)); - child.on("close", (code, signal) => { - if (signal) { - reject(new Error(`${relativePath} exited with signal ${signal}`)); - return; + if (testFiles.length === 0) { + console.log("No unit tests found."); + return; + } + + const shard = parseShard(shardValue); + const selectedTests = shard + ? testFiles.filter((_, index) => index % shard.total === shard.index - 1) + : testFiles; + + if (shard) { + console.log( + `Using shard ${shard.index}/${shard.total} (${selectedTests.length} of ${testFiles.length} files).`, + ); + } + + if (selectedTests.length === 0) { + console.log("No unit tests found for this shard."); + return; + } + + for (const testFile of selectedTests) { + 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", + }); + let finished = false; + let timeoutId = null; + + const finalize = (error) => { + if (finished) { + return; + } + finished = true; + + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (error) { + reject(error); + return; + } + + resolve(); + }; + + if (timeoutMs !== null) { + timeoutId = setTimeout(() => { + child.kill("SIGKILL"); + finalize( + new Error(`${relativePath} timed out after ${timeoutMs}ms and was aborted.`), + ); + }, timeoutMs); } - if (code !== 0) { - reject(new Error(`${relativePath} failed with exit code ${code}`)); - return; - } + child.on("close", (code, signal) => { + if (signal) { + finalize(new Error(`${relativePath} exited with signal ${signal}`)); + return; + } - resolve(); - }); + if (code !== 0) { + finalize(new Error(`${relativePath} failed with exit code ${code}`)); + return; + } - child.on("error", (error) => { - reject(error); + finalize(); + }); + + child.on("error", (error) => { + finalize(error); + }); }); - }); + } + + console.log("\n✔ All unit tests passed"); } -console.log("\n✔ All unit tests passed"); +try { + await run(); +} catch (error) { + console.error(`\n✖ ${error.message}`); + process.exit(1); +}