mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-06 12:52:26 +00:00
Add unit test sharding and timeouts
This commit is contained in:
1 parent
fd3ba76150
commit
054fca8433
4 files changed
+168
-32
No files matched your search
@@ -70,13 +70,53 @@ jobs:
|
|||||||
test-results
|
test-results
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: npm run test:unit
|
|
||||||
|
|
||||||
- name: Fail if visual regressions detected
|
- name: Fail if visual regressions detected
|
||||||
if: steps.visual.outcome == 'failure'
|
if: steps.visual.outcome == 'failure'
|
||||||
run: exit 1
|
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:
|
dm-unit-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ placeholder at “—” and development builds log a warning—so mixed deploym
|
|||||||
4. **Make Changes**:
|
4. **Make Changes**:
|
||||||
- Ensure your code follows best practices and is well-documented.
|
- Ensure your code follows best practices and is well-documented.
|
||||||
5. **Test**:
|
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.
|
- Validate the site functionality locally before submitting.
|
||||||
6. **Submit a Pull Request**:
|
6. **Submit a Pull Request**:
|
||||||
- Explain your changes and reference any related issues.
|
- 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.
|
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.
|
Before pushing, run `npm run build` locally so the Tailwind bundle regenerates.
|
||||||
Pair that with `npm run test:unit` for application logic changes and
|
Pair that with `npm run test:unit` for application logic changes, or a shard
|
||||||
`npm run test:visual` for presentation updates to mirror the CI surface area.
|
(`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
|
For DM-specific changes, also run `npm run test:dm:unit` and
|
||||||
`npm run test:dm:integration` to cover the direct message flows.
|
`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`.
|
Use `npm run test:e2e` to execute the headless Playwright journeys in `tests/e2e`.
|
||||||
|
|||||||
@@ -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: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: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": "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"
|
"telemetry:aggregate": "node scripts/telemetry-aggregator.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+115
-26
@@ -7,6 +7,17 @@ const rootDir = path.dirname(fileURLToPath(new URL("../package.json", import.met
|
|||||||
const testsDir = path.join(rootDir, "tests");
|
const testsDir = path.join(rootDir, "tests");
|
||||||
const testFiles = [];
|
const testFiles = [];
|
||||||
const setupImport = new URL("../tests/test-helpers/setup-localstorage.mjs", import.meta.url);
|
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) {
|
async function collectTests(dir) {
|
||||||
const entries = await readdir(dir, { withFileTypes: true });
|
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 <index>/<total>, e.g. 1/3.");
|
||||||
|
}
|
||||||
|
|
||||||
if (testFiles.length === 0) {
|
const index = Number(match[1]);
|
||||||
console.log("No unit tests found.");
|
const total = Number(match[2]);
|
||||||
process.exit(0);
|
|
||||||
|
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) {
|
async function run() {
|
||||||
const relativePath = path.relative(rootDir, testFile);
|
await collectTests(testsDir);
|
||||||
console.log(`\n→ Running ${relativePath}`);
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
testFiles.sort((a, b) => a.localeCompare(b));
|
||||||
const child = spawn(process.execPath, ["--import", setupImport.href, testFile], {
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on("close", (code, signal) => {
|
if (testFiles.length === 0) {
|
||||||
if (signal) {
|
console.log("No unit tests found.");
|
||||||
reject(new Error(`${relativePath} exited with signal ${signal}`));
|
return;
|
||||||
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) {
|
child.on("close", (code, signal) => {
|
||||||
reject(new Error(`${relativePath} failed with exit code ${code}`));
|
if (signal) {
|
||||||
return;
|
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) => {
|
finalize();
|
||||||
reject(error);
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user