Skip to content

ci(e2e): balance parallel nodes with measured per-file timings instead of filesize split#10423

Open
davidfirst wants to merge 9 commits into
masterfrom
ci-e2e-timing-split
Open

ci(e2e): balance parallel nodes with measured per-file timings instead of filesize split#10423
davidfirst wants to merge 9 commits into
masterfrom
ci-e2e-timing-split

Conversation

@davidfirst

@davidfirst davidfirst commented Jun 11, 2026

Copy link
Copy Markdown
Member

Replaces circleci tests split --split-by=filesize with deterministic bin-packing based on measured per-file wall-clock times, and raises e2e_test parallelism to 40.

Result: e2e job 34 min → 16 min (-53%) at ~+5% cost (463 → 487 billed machine-minutes).

Round Change Job time
baseline filesize split, 25 nodes ~34 min
1 measured-timing bin-packing 25:43
2 recalibrated timings + split deps-graph + 32 nodes 21:10
3 floor for zero-weight files + split custom-env 20:10
4 recalibration from 32-node runs + split merge-lanes-edge-cases + 40 nodes 16:08

Why not CircleCI's --split-by=timings: it relies on per-testcase junit durations, but ~85% of our e2e time is in before/after hooks, which mocha attributes to no testcase — the splitter sees ~13% of the real cost. Node times ranged 9.7–32.2 min for the same job; the slowest node sets the job duration.

How it works:

  • scripts/e2e-test-timings.json — per-file durations derived from actual CI node run times (each node is one equation: wall = overhead + sum of its files; solved with non-negative least squares).
  • scripts/split-e2e-tests.js — greedy LPT packing; each node independently computes the same assignment and picks its share via CIRCLE_NODE_INDEX. New files not in the manifest get the median weight.
  • scripts/generate-e2e-timings.js — regenerates the manifest from the CircleCI public API (no token needed); run occasionally to refresh.
  • Three oversized files (deps-graph, custom-env, merge-lanes-edge-cases, each 11-17 min alone) were split at describe boundaries — pure moves, no test changes — since the longest single file is a hard floor on any packing.

Parallelism 40 is roughly cost-neutral because CircleCI bills per container-minute and total work is constant; only per-container spin-up/cache overhead multiplies.

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 11, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (9) 📘 Rule violations (1)

Grey Divider


Action required

1. Unreachable failure assertion 🐞 Bug ≡ Correctness
Description
The test expects helper.command.mergeLane() to return output when the merge fails, but mergeLane()
runs via execSync and throws on non-zero exit so the assertion on the returned output is never
reached. This makes the new regression test fail (or fail to validate the intended failure mode).
Code

e2e/harmony/lanes/merge-lanes-edge-cases-2.e2e.ts[R436-439]

+      it('should fail when merging with --build due to test failures', () => {
+        const output = helper.command.mergeLane('dev', '--build --no-squash');
+        expect(output).to.have.string('Total Snapped: 0');
+      });
Evidence
The test asserts on a return string from mergeLane(), but mergeLane() calls runCmd() which uses
execSync and will throw for failing commands, preventing the assertion from executing.

e2e/harmony/lanes/merge-lanes-edge-cases-2.e2e.ts[436-439]
components/legacy/e2e-helper/e2e-command-helper.ts[850-853]
components/legacy/e2e-helper/e2e-command-helper.ts[106-129]
components/legacy/e2e-helper/e2e-general-helper.ts[81-89]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test `should fail when merging with --build due to test failures` calls `helper.command.mergeLane(...)` and asserts on its return value. However, `mergeLane()` ultimately uses `execSync` via `runCmd()`, so when the merge fails it throws and never returns a string, making the assertion unreachable.
### Issue Context
`CommandHelper.mergeLane()` delegates to `runCmd()`, which uses `childProcess.execSync(...)` and will throw on non-zero exit codes.
### Fix Focus Areas
- e2e/harmony/lanes/merge-lanes-edge-cases-2.e2e.ts[436-439]
### Recommended fix
Update the test to assert on the failure using one of the existing patterns used elsewhere in this repo:
- Use `helper.general.runWithTryCatch('bit lane merge ...')` (or add a `mergeLaneWithTryCatch` helper) and then assert on the returned output string.
- Or wrap the call in `expect(() => helper.command.mergeLane(...)).to.throw()` and validate the error message/output as needed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Raw output in generate-e2e-timings.js 📘 Rule violation ⚙ Maintainability
Description
The new scripts scripts/generate-e2e-timings.js and scripts/split-e2e-tests.js emit progress,
warning/note, and stats output using raw console.error and
process.stderr.write/process.stdout.write rather than the shared CLI output formatter. This
violates the repository’s CLI Output Style Guide and can lead to inconsistent CLI styling across the
repo.
Code

scripts/generate-e2e-timings.js[R126-158]

+async function main() {
+  console.error('finding recent successful e2e_test jobs...');
+  const jobNumbers = await findRecentE2eJobs();
+  console.error(`collecting node data from ${jobNumbers.length} jobs...`);
+  const observations = [];
+  for (const jobNumber of jobNumbers) {
+    const nodes = await fetchNodeObservations(jobNumber);
+    observations.push(...nodes);
+    console.error(`  job ${jobNumber}: ${nodes.length} nodes`);
+  }
+  if (observations.length < 50) {
+    throw new Error(`only ${observations.length} node observations collected - not enough to solve reliably`);
+  }
+  const files = [...new Set(observations.flatMap((o) => o.files))].sort();
+  console.error(`solving for ${files.length} files from ${observations.length} node observations...`);
+  const { estimate, fixed, meanError } = solve(observations, files);
+  console.error(
+    `fixed per-node overhead: ${Math.round(fixed)}s, mean node prediction error: ${(meanError * 100).toFixed(1)}%`
+  );
+  if (meanError > 0.15) {
+    console.error('warning: prediction error is high; estimates may be stale or assignments lacked diversity');
+  }
+
+  // floor at 15s: the solver can collapse under-identified files to 0, which makes the
+  // bin-packer treat them as free and pile dozens of them onto one node
+  const manifest = Object.fromEntries(files.map((f, i) => [f, Math.max(15, Math.round(estimate[i]))]));
+  fs.writeFileSync(OUT_FILE, `${JSON.stringify(manifest, null, 2)}\n`);
+  console.error(`wrote ${OUT_FILE}`);
+}
+
+main().catch((err) => {
+  console.error(err);
+  process.exit(1);
Evidence
The CLI Output Style Guide (scopes/harmony/cli/cli-output-style-guide.md) requires that CLI output
use the shared formatting toolkit provided via @teambit/cli (implemented in
scopes/harmony/cli/output-formatter.ts). In the added scripts, multiple user-facing lines
(including warning:/note: and progress/stats messages) are written directly using
console.error in scripts/generate-e2e-timings.js and direct
process.stderr.write/process.stdout.write in scripts/split-e2e-tests.js, demonstrating that
the shared formatter utilities are not being used where required.

CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide
CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols)
scripts/generate-e2e-timings.js[126-158]
scripts/split-e2e-tests.js[41-96]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`scripts/generate-e2e-timings.js` and `scripts/split-e2e-tests.js` emit CLI output (progress, warning/note, and stats lines) using raw `console.error` and `process.stderr.write` / `process.stdout.write`. The repo requires CLI output to follow the CLI Output Style Guide and use the shared output formatting toolkit so messaging is consistent.
## Issue Context
The CLI Output Style Guide (`scopes/harmony/cli/cli-output-style-guide.md`) mandates using the shared toolkit from `@teambit/cli` (`scopes/harmony/cli/output-formatter.ts`) for CLI output. This PR introduces new CLI output in both scripts without using that toolkit.
## Fix Focus Areas
- scripts/generate-e2e-timings.js[126-158]
- scripts/split-e2e-tests.js[41-96]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Truncated log parsing 🐞 Bug ☼ Reliability ⭐ New
Description
scripts/generate-e2e-timings.js only inspects output[0].message and truncates it to 10,000
characters, so it can miss executed file paths when CircleCI returns multiple log chunks or the
command line exceeds the truncation window. This can yield an incomplete timings manifest and
degrade the accuracy of the e2e node balancing derived from it.
Code

scripts/generate-e2e-timings.js[R66-70]

+      const output = await getJson(action.output_url);
+      const message = output[0].message.slice(0, 10000);
+      const files = [
+        ...new Set([...message.matchAll(/\/home\/circleci\/bit\/bit\/(e2e\/\S+?\.e2e\S*?\.ts)/g)].map((m) => m[1])),
+      ];
Evidence
The script fetches action.output_url and then only parses output[0].message.slice(0, 10000)
before regex-matching for e2e paths, which necessarily ignores any matches that occur outside the
first chunk or beyond the truncation limit.

scripts/generate-e2e-timings.js[66-71]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`scripts/generate-e2e-timings.js` extracts executed e2e file paths from CircleCI logs, but it only reads `output[0].message` and slices it to 10,000 chars. If the file list appears in later log entries or after the first 10k chars, those files are silently ignored, producing a degraded timings manifest.

### Issue Context
CircleCI `action.output_url` returns a JSON array of output entries; relying on only the first element and truncating can drop relevant text.

### Fix Focus Areas
- scripts/generate-e2e-timings.js[66-71]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Fetch breaks older Node 🐞 Bug ☼ Reliability
Description
scripts/generate-e2e-timings.js uses the global fetch() API, which is not available on Node
versions allowed by package.json (>=12.22.0), so running the regeneration script can crash
immediately on supported runtimes.
Code

scripts/generate-e2e-timings.js[R30-33]

+async function getJson(url) {
+  const res = await fetch(url);
+  if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
+  return res.json();
Evidence
The regeneration script directly calls fetch(), while the repo declares support for Node >=12.22.0
where fetch is not guaranteed, so the script can throw ReferenceError: fetch is not defined on
supported Node versions.

scripts/generate-e2e-timings.js[30-33]
package.json[5-7]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`scripts/generate-e2e-timings.js` calls `fetch()` directly. This is incompatible with the repository’s declared Node engine range (`>=12.22.0`), where `fetch` is not guaranteed to exist, causing the script to fail at runtime.
### Issue Context
This script is intended to be run manually to refresh `scripts/e2e-test-timings.json`, so it should either (a) work across the declared Node range or (b) fail with a clear, intentional runtime requirement.
### Fix Focus Areas
- Implement HTTP JSON fetch without relying on global `fetch` (e.g., using `https`), or explicitly guard and error with a clear message (or polyfill) when `fetch` is unavailable.
- scripts/generate-e2e-timings.js[20-34]
- package.json[5-7]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Invalid node total crashes 🐞 Bug ☼ Reliability
Description
scripts/split-e2e-tests.js does not validate CIRCLE_NODE_TOTAL; if it parses to 0/NaN, it creates
an empty bins array and then calls reduce() on it, crashing the split step before tests run.
Code

scripts/split-e2e-tests.js[R56-80]

+function main() {
+  const nodeTotal = parseInt(process.env.CIRCLE_NODE_TOTAL || '1', 10);
+  const nodeIndex = parseInt(process.env.CIRCLE_NODE_INDEX || '0', 10);
+  const showStats = process.argv.includes('--stats');
+
+  const timings = loadTimings();
+  const defaultWeight = median(Object.values(timings));
+
+  const files = findE2eFiles(E2E_DIR).map((abs) => {
+    const rel = path.relative(REPO_ROOT, abs).split(path.sep).join('/');
+    const weight = timings[rel] ?? defaultWeight;
+    if (!(rel in timings)) {
+      process.stderr.write(`note: ${rel} not in timings manifest, assuming ${defaultWeight}s\n`);
+    }
+    return { abs, rel, weight };
+  });
+
+  // LPT bin-packing: heaviest first, each file goes to the least-loaded node.
+  // Sort is fully deterministic (weight desc, then path) so every node computes
+  // the same assignment independently.
+  files.sort((a, b) => b.weight - a.weight || a.rel.localeCompare(b.rel));
+  const bins = Array.from({ length: nodeTotal }, () => ({ load: 0, files: [] }));
+  for (const file of files) {
+    const bin = bins.reduce((min, b) => (b.load < min.load ? b : min));
+    bin.load += file.weight;
Evidence
nodeTotal is parsed from env without validation, used as the bins array length, and
bins.reduce(...) is invoked without an initial value; reduce throws on an empty array.

scripts/split-e2e-tests.js[56-82]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`scripts/split-e2e-tests.js` assumes `CIRCLE_NODE_TOTAL` is a positive integer. If it is missing/malformed/0, `Array.from({ length: nodeTotal })` yields an empty array and `bins.reduce(...)` throws, failing CI before running any tests.
### Issue Context
This script is now in the critical path for CircleCI e2e execution.
### Fix Focus Areas
- Add a guard like `if (!Number.isInteger(nodeTotal) || nodeTotal < 1) throw new Error(...)` (or default to 1 with a warning).
- scripts/split-e2e-tests.js[56-82]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (5)
6. No-op Chai assertion 🐞 Bug ≡ Correctness
Description
In e2e/harmony/custom-env-operations.e2e.ts, a test wraps helper.command.setEnv in expect(...)
but never asserts (e.g. .to.not.throw()), so the function is never executed and the regression
isn’t actually tested.
Code

e2e/harmony/custom-env-operations.e2e.ts[R49-51]

+    it('bit env-set should not throw any error', () => {
+      expect(() => helper.command.setEnv('comp1', envId));
+    });
Evidence
The file demonstrates the intended Chai pattern (expect(fn).not.to.throw()), but the env-set test
omits the matcher so setEnv is never invoked and no failure can be detected.

e2e/harmony/custom-env-operations.e2e.ts[30-33]
e2e/harmony/custom-env-operations.e2e.ts[49-51]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test `bit env-set should not throw any error` currently does `expect(() => helper.command.setEnv('comp1', envId));` with no assertion chain. This does not execute `setEnv` and always passes, defeating the regression coverage.
### Issue Context
The surrounding tests correctly use `.not.to.throw()` to assert behavior.
### Fix Focus Areas
- Change to `expect(() => helper.command.setEnv('comp1', envId)).to.not.throw();` OR simply call `helper.command.setEnv(...)` in the test body.
- e2e/harmony/custom-env-operations.e2e.ts[49-51]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Hardcoded CircleCI path 🐞 Bug ☼ Reliability
Description
scripts/generate-e2e-timings.js hardcodes the CircleCI workspace prefix (/home/circleci/bit/bit)
when extracting executed e2e file paths from logs, so any future change to CircleCI checkout/working
directory layout will cause it to miss files and potentially fail to collect enough observations to
regenerate the manifest.
Code

scripts/generate-e2e-timings.js[R67-71]

+      const message = output[0].message.slice(0, 10000);
+      const files = [
+        ...new Set([...message.matchAll(/\/home\/circleci\/bit\/bit\/(e2e\/\S+?\.e2e\S*?\.ts)/g)].map((m) => m[1])),
+      ];
+      if (files.length) nodes.push({ wall: action.run_time_millis / 1000, files });
Evidence
The generator explicitly matches only paths that start with /home/circleci/bit/bit/…, while the
CircleCI working directory and cd bit behavior are configuration details; if those change, file
extraction breaks and nodes contribute no equations.

scripts/generate-e2e-timings.js[57-71]
.circleci/config.yml[15-18]
.circleci/config.yml[490-501]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`scripts/generate-e2e-timings.js` parses file paths from CircleCI step output using a regex anchored to a specific absolute workspace path (`/home/circleci/bit/bit`). If CircleCI’s working directory or checkout layout changes, the regex will stop matching and the generator will silently collect fewer/no equations and may abort due to insufficient observations.
### Issue Context
The CircleCI config currently uses `working_directory: ~/bit` and then runs tests after `cd bit`, so the current absolute prefix happens to match. This is a CI detail that can change independently of this script.
### Fix Focus Areas
- scripts/generate-e2e-timings.js[58-75]
- .circleci/config.yml[15-18]
- .circleci/config.yml[490-501]
### Suggested fix
- Change the file-extraction to match the `e2e/...` suffix without relying on the absolute prefix, e.g. capture `/(^|\s)(e2e\/\S+?\.e2e\S*?\.ts)/g` and then normalize.
- Optionally, support both absolute and relative forms by stripping any prefix up to `/e2e/` before storing.
- If no files are matched for a successful node, emit a warning with enough context (job/node) to diagnose parsing drift.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Empty split still runs 🐞 Bug ☼ Reliability
Description
The CircleCI command pipes the splitter into xargs without --no-run-if-empty/-r, so a
zero-path splitter output still invokes npm run mocha-circleci once. Since mocha-circleci
doesn’t include an e2e file glob itself, running it without file args won’t run the intended split
selection.
Code

.circleci/config.yml[498]

+          command: 'cd bit && node scripts/split-e2e-tests.js | xargs npm run mocha-circleci << parameters.bit_bin >>'
Evidence
The new CI step uses xargs directly on the splitter output, and the mocha-circleci script
definition shows mocha is invoked without a test-file glob—so it depends on receiving explicit file
args from the split step.

.circleci/config.yml[490-501]
package.json[33-38]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
If `split-e2e-tests.js` emits no paths (e.g., e2e path changes, unexpected filtering, or directory missing), `xargs` will still run the downstream command once, causing `mocha-circleci` to run without the intended file list.
### Issue Context
`mocha-circleci` is defined as a mocha invocation without an embedded e2e glob; it relies on the file arguments that come from `xargs`.
### Fix Focus Areas
- .circleci/config.yml[490-501]
- package.json[33-38]
### Suggested fix
Change the CircleCI command to avoid running when there is no input, e.g.:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Unvalidated node index 🐞 Bug ☼ Reliability
Description
scripts/split-e2e-tests.js only validates CIRCLE_NODE_INDEX >= CIRCLE_NODE_TOTAL; if
CIRCLE_NODE_INDEX parses to NaN (non-numeric) or a negative number, bins[nodeIndex] is
undefined and the script crashes before emitting any file paths for xargs.
Code

scripts/split-e2e-tests.js[R56-96]

+function main() {
+  const nodeTotal = parseInt(process.env.CIRCLE_NODE_TOTAL || '1', 10);
+  const nodeIndex = parseInt(process.env.CIRCLE_NODE_INDEX || '0', 10);
+  const showStats = process.argv.includes('--stats');
+
+  const timings = loadTimings();
+  const defaultWeight = median(Object.values(timings));
+
+  const files = findE2eFiles(E2E_DIR).map((abs) => {
+    const rel = path.relative(REPO_ROOT, abs).split(path.sep).join('/');
+    const weight = timings[rel] ?? defaultWeight;
+    if (!(rel in timings)) {
+      process.stderr.write(`note: ${rel} not in timings manifest, assuming ${defaultWeight}s\n`);
+    }
+    return { abs, rel, weight };
+  });
+
+  // LPT bin-packing: heaviest first, each file goes to the least-loaded node.
+  // Sort is fully deterministic (weight desc, then path) so every node computes
+  // the same assignment independently.
+  files.sort((a, b) => b.weight - a.weight || a.rel.localeCompare(b.rel));
+  const bins = Array.from({ length: nodeTotal }, () => ({ load: 0, files: [] }));
+  for (const file of files) {
+    const bin = bins.reduce((min, b) => (b.load < min.load ? b : min));
+    bin.load += file.weight;
+    bin.files.push(file);
+  }
+
+  if (showStats) {
+    bins.forEach((bin, i) => {
+      process.stdout.write(`node ${i}: ${(bin.load / 60).toFixed(1)} min, ${bin.files.length} files\n`);
+    });
+    return;
+  }
+
+  if (nodeIndex >= nodeTotal) {
+    throw new Error(`CIRCLE_NODE_INDEX (${nodeIndex}) must be smaller than CIRCLE_NODE_TOTAL (${nodeTotal})`);
+  }
+  for (const file of bins[nodeIndex].files) {
+    process.stdout.write(`${file.abs}\n`);
+  }
Evidence
The script parses CIRCLE_NODE_INDEX via parseInt and later indexes bins[nodeIndex] after only
checking nodeIndex >= nodeTotal, so NaN/negative values bypass the check and lead to a crash
when iterating .files.

scripts/split-e2e-tests.js[56-59]
scripts/split-e2e-tests.js[91-96]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`scripts/split-e2e-tests.js` does not validate `CIRCLE_NODE_INDEX` for being a finite integer within `[0, CIRCLE_NODE_TOTAL)`. If it parses to `NaN` or a negative number, the script will throw when accessing `bins[nodeIndex].files`, failing the CI step.
### Issue Context
The script currently parses both env vars with `parseInt()` and only checks the upper bound (`nodeIndex >= nodeTotal`).
### Fix Focus Areas
- scripts/split-e2e-tests.js[56-96]
### Suggested fix
- Validate `nodeTotal` and `nodeIndex` with something like:
- `Number.isInteger(nodeTotal) && nodeTotal > 0`
- `Number.isInteger(nodeIndex) && nodeIndex >= 0 && nodeIndex < nodeTotal`
- Throw a clear error message when invalid (include the raw env var values if helpful).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Silent observation loss 🐞 Bug ◔ Observability
Description
scripts/generate-e2e-timings.js swallows all exceptions when fetching/parsing node output, so
expired/unexpected output formats silently reduce the observation set and can cause unexplained
solver failures or low-quality manifests.
Code

scripts/generate-e2e-timings.js[R63-75]

+  for (const action of step.actions) {
+    if (action.status !== 'success' || !action.output_url) continue;
+    try {
+      const output = await getJson(action.output_url);
+      const message = output[0].message.slice(0, 10000);
+      const files = [
+        ...new Set([...message.matchAll(/\/home\/circleci\/bit\/bit\/(e2e\/\S+?\.e2e\S*?\.ts)/g)].map((m) => m[1])),
+      ];
+      if (files.length) nodes.push({ wall: action.run_time_millis / 1000, files });
+    } catch {
+      // a node whose output expired or failed to parse just contributes no equation
+    }
+  }
Evidence
The generator explicitly ignores any error while reading a node’s output (bare catch), which means
observation loss is silent and debugging missing equations is difficult.

scripts/generate-e2e-timings.js[57-76]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`scripts/generate-e2e-timings.js` uses a bare `catch {}` when fetching/parsing each node’s output. This makes it impossible to tell why observations are missing (e.g., output expired, API shape changed), and can lead to confusing failures like “only N node observations collected” with no actionable cause.
### Issue Context
Inside `fetchNodeObservations()`, failures are silently ignored and the node contributes no equation.
### Fix Focus Areas
- scripts/generate-e2e-timings.js[57-77]
### Suggested fix
- Change `catch {}` to `catch (err)` and emit a concise warning to stderr including identifiers (jobNumber, action index/name if available, and `err.message`).
- Optionally count skipped nodes and print a summary per job (e.g., `parsed X nodes, skipped Y`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit d1c55ed

Results up to commit N/A


🐞 Bugs (8) 📘 Rule violations (1) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0)


Action required
1. Unreachable failure assertion 🐞 Bug ≡ Correctness
Description
The test expects helper.command.mergeLane() to return output when the merge fails, but mergeLane()
runs via execSync and throws on non-zero exit so the assertion on the returned output is never
reached. This makes the new regression test fail (or fail to validate the intended failure mode).
Code

e2e/harmony/lanes/merge-lanes-edge-cases-2.e2e.ts[R436-439]

+      it('should fail when merging with --build due to test failures', () => {
+        const output = helper.command.mergeLane('dev', '--build --no-squash');
+        expect(output).to.have.string('Total Snapped: 0');
+      });
Evidence
The test asserts on a return string from mergeLane(), but mergeLane() calls runCmd() which uses
execSync and will throw for failing commands, preventing the assertion from executing.

e2e/harmony/lanes/merge-lanes-edge-cases-2.e2e.ts[436-439]
components/legacy/e2e-helper/e2e-command-helper.ts[850-853]
components/legacy/e2e-helper/e2e-command-helper.ts[106-129]
components/legacy/e2e-helper/e2e-general-helper.ts[81-89]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test `should fail when merging with --build due to test failures` calls `helper.command.mergeLane(...)` and asserts on its return value. However, `mergeLane()` ultimately uses `execSync` via `runCmd()`, so when the merge fails it throws and never returns a string, making the assertion unreachable.
### Issue Context
`CommandHelper.mergeLane()` delegates to `runCmd()`, which uses `childProcess.execSync(...)` and will throw on non-zero exit codes.
### Fix Focus Areas
- e2e/harmony/lanes/merge-lanes-edge-cases-2.e2e.ts[436-439]
### Recommended fix
Update the test to assert on the failure using one of the existing patterns used elsewhere in this repo:
- Use `helper.general.runWithTryCatch('bit lane merge ...')` (or add a `mergeLaneWithTryCatch` helper) and then assert on the returned output string.
- Or wrap the call in `expect(() => helper.command.mergeLane(...)).to.throw()` and validate the error message/output as needed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Raw output in generate-e2e-timings.js 📘 Rule violation ⚙ Maintainability
Description
The new scripts scripts/generate-e2e-timings.js and scripts/split-e2e-tests.js emit progress,
warning/note, and stats output using raw console.error and
process.stderr.write/process.stdout.write rather than the shared CLI output formatter. This
violates the repository’s CLI Output Style Guide and can lead to inconsistent CLI styling across the
repo.
Code

scripts/generate-e2e-timings.js[R126-158]

+async function main() {
+  console.error('finding recent successful e2e_test jobs...');
+  const jobNumbers = await findRecentE2eJobs();
+  console.error(`collecting node data from ${jobNumbers.length} jobs...`);
+  const observations = [];
+  for (const jobNumber of jobNumbers) {
+    const nodes = await fetchNodeObservations(jobNumber);
+    observations.push(...nodes);
+    console.error(`  job ${jobNumber}: ${nodes.length} nodes`);
+  }
+  if (observations.length < 50) {
+    throw new Error(`only ${observations.length} node observations collected - not enough to solve reliably`);
+  }
+  const files = [...new Set(observations.flatMap((o) => o.files))].sort();
+  console.error(`solving for ${files.length} files from ${observations.length} node observations...`);
+  const { estimate, fixed, meanError } = solve(observations, files);
+  console.error(
+    `fixed per-node overhead: ${Math.round(fixed)}s, mean node prediction error: ${(meanError * 100).toFixed(1)}%`
+  );
+  if (meanError > 0.15) {
+    console.error('warning: prediction error is high; estimates may be stale or assignments lacked diversity');
+  }
+
+  // floor at 15s: the solver can collapse under-identified files to 0, which makes the
+  // bin-packer treat them as free and pile dozens of them onto one node
+  const manifest = Object.fromEntries(files.map((f, i) => [f, Math.max(15, Math.round(estimate[i]))]));
+  fs.writeFileSync(OUT_FILE, `${JSON.stringify(manifest, null, 2)}\n`);
+  console.error(`wrote ${OUT_FILE}`);
+}
+
+main().catch((err) => {
+  console.error(err);
+  process.exit(1);
Evidence
The CLI Output Style Guide (scopes/harmony/cli/cli-output-style-guide.md) requires that CLI output
use the shared formatting toolkit provided via @teambit/cli (implemented in
scopes/harmony/cli/output-formatter.ts). In the added scripts, multiple user-facing lines
(including warning:/note: and progress/stats messages) are written directly using
console.error in scripts/generate-e2e-timings.js and direct
process.stderr.write/process.stdout.write in scripts/split-e2e-tests.js, demonstrating that
the shared formatter utilities are not being used where required.

CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide: CLAUDE.md: CLI Output Changes Must Follow the CLI Output Style Guide
CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols): CLAUDE.md: Use Shared CLI Output Formatting Toolkit (Do Not Hardcode Chalk Styles or Unicode Symbols)
scripts/generate-e2e-timings.js[126-158]
scripts/split-e2e-tests.js[41-96]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`scripts/generate-e2e-timings.js` and `scripts/split-e2e-tests.js` emit CLI output (progress, warning/note, and stats lines) using raw `console.error` and `process.stderr.write` / `process.stdout.write`. The repo requires CLI output to follow the CLI Output Style Guide and use the shared output formatting toolkit so messaging is consistent.
## Issue Context
The CLI Output Style Guide (`scopes/harmony/cli/cli-output-style-guide.md`) mandates using the shared toolkit from `@teambit/cli` (`scopes/harmony/cli/output-formatter.ts`) for CLI output. This PR introduces new CLI output in both scripts without using that toolkit.
## Fix Focus Areas
- scripts/generate-e2e-timings.js[126-158]
- scripts/split-e2e-tests.js[41-96]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
3. Fetch breaks older Node 🐞 Bug ☼ Reliability
Description
scripts/generate-e2e-timings.js uses the global fetch() API, which is not available on Node
versions allowed by package.json (>=12.22.0), so running the regeneration script can crash
immediately on supported runtimes.
Code

scripts/generate-e2e-timings.js[R30-33]

+async function getJson(url) {
+  const res = await fetch(url);
+  if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
+  return res.json();
Evidence
The regeneration script directly calls fetch(), while the repo declares support for Node >=12.22.0
where fetch is not guaranteed, so the script can throw ReferenceError: fetch is not defined on
supported Node versions.

scripts/generate-e2e-timings.js[30-33]
package.json[5-7]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`scripts/generate-e2e-timings.js` calls `fetch()` directly. This is incompatible with the repository’s declared Node engine range (`>=12.22.0`), where `fetch` is not guaranteed to exist, causing the script to fail at runtime.
### Issue Context
This script is intended to be run manually to refresh `scripts/e2e-test-timings.json`, so it should either (a) work across the declared Node range or (b) fail with a clear, intentional runtime requirement.
### Fix Focus Areas
- Implement HTTP JSON fetch without relying on global `fetch` (e.g., using `https`), or explicitly guard and error with a clear message (or polyfill) when `fetch` is unavailable.
- scripts/generate-e2e-timings.js[20-34]
- package.json[5-7]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Invalid node total crashes 🐞 Bug ☼ Reliability
Description
scripts/split-e2e-tests.js does not validate CIRCLE_NODE_TOTAL; if it parses to 0/NaN, it creates
an empty bins array and then calls reduce() on it, crashing the split step before tests run.
Code

scripts/split-e2e-tests.js[R56-80]

+function main() {
+  const nodeTotal = parseInt(process.env.CIRCLE_NODE_TOTAL || '1', 10);
+  const nodeIndex = parseInt(process.env.CIRCLE_NODE_INDEX || '0', 10);
+  const showStats = process.argv.includes('--stats');
+
+  const timings = loadTimings();
+  const defaultWeight = median(Object.values(timings));
+
+  const files = findE2eFiles(E2E_DIR).map((abs) => {
+    const rel = path.relative(REPO_ROOT, abs).split(path.sep).join('/');
+    const weight = timings[rel] ?? defaultWeight;
+    if (!(rel in timings)) {
+      process.stderr.write(`note: ${rel} not in timings manifest, assuming ${defaultWeight}s\n`);
+    }
+    return { abs, rel, weight };
+  });
+
+  // LPT bin-packing: heaviest first, each file goes to the least-loaded node.
+  // Sort is fully deterministic (weight desc, then path) so every node computes
+  // the same assignment independently.
+  files.sort((a, b) => b.weight - a.weight || a.rel.localeCompare(b.rel));
+  const bins = Array.from({ length: nodeTotal }, () => ({ load: 0, files: [] }));
+  for (const file of files) {
+    const bin = bins.reduce((min, b) => (b.load < min.load ? b : min));
+    bin.load += file.weight;
Evidence
nodeTotal is parsed from env without validation, used as the bins array length, and
bins.reduce(...) is invoked without an initial value; reduce throws on an empty array.

scripts/split-e2e-tests.js[56-82]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`scripts/split-e2e-tests.js` assumes `CIRCLE_NODE_TOTAL` is a positive integer. If it is missing/malformed/0, `Array.from({ length: nodeTotal })` yields an empty array and `bins.reduce(...)` throws, failing CI before running any tests.
### Issue Context
This script is now in the critical path for CircleCI e2e execution.
### Fix Focus Areas
- Add a guard like `if (!Number.isInteger(nodeTotal) || nodeTotal < 1) throw new Error(...)` (or default to 1 with a warning).
- scripts/split-e2e-tests.js[56-82]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. No-op Chai assertion 🐞 Bug ≡ Correctness
Description
In e2e/harmony/custom-env-operations.e2e.ts, a test wraps helper.command.setEnv in expect(...)
but never asserts (e.g. .to.not.throw()), so the function is never executed and the regression
isn’t actually tested.
Code

e2e/harmony/custom-env-operations.e2e.ts[R49-51]

+    it('bit env-set should not throw any error', () => {
+      expect(() => helper.command.setEnv('comp1', envId));
+    });
Evidence
The file demonstrates the intended Chai pattern (expect(fn).not.to.throw()), but the env-set test
omits the matcher so setEnv is never invoked and no failure can be detected.

e2e/harmony/custom-env-operations.e2e.ts[30-33]
e2e/harmony/custom-env-operations.e2e.ts[49-51]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test `bit env-set should not throw any error` currently does `expect(() => helper.command.setEnv('comp1', envId));` with no assertion chain. This does not execute `setEnv` and always passes, defeating the regression coverage.
### Issue Context
The surrounding tests correctly use `.not.to.throw()` to assert behavior.
### Fix Focus Areas
- Change to `expect(() => helper.command.setEnv('comp1', envId)).to.not.throw();` OR simply call `helper.command.setEnv(...)` in the test body.
- e2e/harmony/custom-env-operations.e2e.ts[49-51]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (4)
6. Hardcoded CircleCI path 🐞 Bug ☼ Reliability
Description
scripts/generate-e2e-timings.js hardcodes the CircleCI workspace prefix (/home/circleci/bit/bit)
when extracting executed e2e file paths from logs, so any future change to CircleCI checkout/working
directory layout will cause it to miss files and potentially fail to collect enough observations to
regenerate the manifest.
Code

scripts/generate-e2e-timings.js[R67-71]

+      const message = output[0].message.slice(0, 10000);
+      const files = [
+        ...new Set([...message.matchAll(/\/home\/circleci\/bit\/bit\/(e2e\/\S+?\.e2e\S*?\.ts)/g)].map((m) => m[1])),
+      ];
+      if (files.length) nodes.push({ wall: action.run_time_millis / 1000, files });
Evidence
The generator explicitly matches only paths that start with /home/circleci/bit/bit/…, while the
CircleCI working directory and cd bit behavior are configuration details; if those change, file
extraction breaks and nodes contribute no equations.

scripts/generate-e2e-timings.js[57-71]
.circleci/config.yml[15-18]
.circleci/config.yml[490-501]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`scripts/generate-e2e-timings.js` parses file paths from CircleCI step output using a regex anchored to a specific absolute workspace path (`/home/circleci/bit/bit`). If CircleCI’s working directory or checkout layout changes, the regex will stop matching and the generator will silently collect fewer/no equations and may abort due to insufficient observations.
### Issue Context
The CircleCI config currently uses `working_directory: ~/bit` and then runs tests after `cd bit`, so the current absolute prefix happens to match. This is a CI detail that can change independently of this script.
### Fix Focus Areas
- scripts/generate-e2e-timings.js[58-75]
- .circleci/config.yml[15-18]
- .circleci/config.yml[490-501]
### Suggested fix
- Change the file-extraction to match the `e2e/...` suffix without relying on the absolute prefix, e.g. capture `/(^|\s)(e2e\/\S+?\.e2e\S*?\.ts)/g` and then normalize.
- Optionally, support both absolute and relative forms by stripping any prefix up to `/e2e/` before storing.
- If no files are matched for a successful node, emit a warning with enough context (job/node) to diagnose parsing drift.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Empty split still runs 🐞 Bug ☼ Reliability
Description
The CircleCI command pipes the splitter into xargs without --no-run-if-empty/-r, so a
zero-path splitter output still invokes npm run mocha-circleci once. Since mocha-circleci
doesn’t include an e2e file glob itself, running it without file args won’t run the intended split
selection.
Code

.circleci/config.yml[498]

+          command: 'cd bit && node scripts/split-e2e-tests.js | xargs npm run mocha-circleci << parameters.bit_bin >>'
Evidence
The new CI step uses xargs directly on the splitter output, and the mocha-circleci script
definition shows mocha is invoked without a test-file glob—so it depends on receiving explicit file
args from the split step.

.circleci/config.yml[490-501]
package.json[33-38]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
If `split-e2e-tests.js` emits no paths (e.g., e2e path changes, unexpected filtering, or directory missing), `xargs` will still run the downstream command once, causing `mocha-circleci` to run without the intended file list.
### Issue Context
`mocha-circleci` is defined as a mocha invocation without an embedded e2e glob; it relies on the file arguments that come from `xargs`.
### Fix Focus Areas
- .circleci/config.yml[490-501]
- package.json[33-38]
### Suggested fix
Change the CircleCI command to avoid running when there is no input, e.g.:

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Unvalidated node index 🐞 Bug ☼ Reliability
Description
scripts/split-e2e-tests.js only validates CIRCLE_NODE_INDEX >= CIRCLE_NODE_TOTAL; if
CIRCLE_NODE_INDEX parses to NaN (non-numeric) or a negative number, bins[nodeIndex] is
undefined and the script crashes before emitting any file paths for xargs.
Code

scripts/split-e2e-tests.js[R56-96]

+function main() {
+  const nodeTotal = parseInt(process.env.CIRCLE_NODE_TOTAL || '1', 10);
+  const nodeIndex = parseInt(process.env.CIRCLE_NODE_INDEX || '0', 10);
+  const showStats = process.argv.includes('--stats');
+
+  const timings = loadTimings();
+  const defaultWeight = median(Object.values(timings));
+
+  const files = findE2eFiles(E2E_DIR).map((abs) => {
+    const rel = path.relative(REPO_ROOT, abs).split(path.sep).join('/');
+    const weight = timings[rel] ?? defaultWeight;
+    if (!(rel in timings)) {
+      process.stderr.write(`note: ${rel} not in timings manifest, assuming ${defaultWeight}s\n`);
+    }
+    return { abs, rel, weight };
+  });
+
+  // LPT bin-packing: heaviest first, each file goes to the least-loaded node.
+  // Sort is fully deterministic (weight desc, then path) so every node computes
+  // the same assignment independently.
+  files.sort((a, b) => b.weight - a.weight || a.rel.localeCompare(b.rel));
+  const bins = Array.from({ length: nodeTotal }, () => ({ load: 0, files: [] }));
+  for (const file of files) {
+    const bin = bins.reduce((min, b) => (b.load < min.load ? b : min));
+    bin.load += file.weight;
+    bin.files.push(file);
+  }
+
+  if (showStats) {
+    bins.forEach((bin, i) => {
+      process.stdout.write(`node ${i}: ${(bin.load / 60).toFixed(1)} min, ${bin.files.length} files\n`);
+    });
+    return;
+  }
+
+  if (nodeIndex >= nodeTotal) {
+    throw new Error(`CIRCLE_NODE_INDEX (${nodeIndex}) must be smaller than CIRCLE_NODE_TOTAL (${nodeTotal})`);
+  }
+  for (const file of bins[nodeIndex].files) {
+    process.stdout.write(`${file.abs}\n`);
+  }
Evidence
The script parses CIRCLE_NODE_INDEX via parseInt and later indexes bins[nodeIndex] after only
checking nodeIndex >= nodeTotal, so NaN/negative values bypass the check and lead to a crash
when iterating .files.

scripts/split-e2e-tests.js[56-59]
scripts/split-e2e-tests.js[91-96]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`scripts/split-e2e-tests.js` does not validate `CIRCLE_NODE_INDEX` for being a finite integer within `[0, CIRCLE_NODE_TOTAL)`. If it parses to `NaN` or a negative number, the script will throw when accessing `bins[nodeIndex].files`, failing the CI step.
### Issue Context
The script currently parses both env vars with `parseInt()` and only checks the upper bound (`nodeIndex >= nodeTotal`).
### Fix Focus Areas
- scripts/split-e2e-tests.js[56-96]
### Suggested fix
- Validate `nodeTotal` and `nodeIndex` with something like:
- `Number.isInteger(nodeTotal) && nodeTotal > 0`
- `Number.isInteger(nodeIndex) && nodeIndex >= 0 && nodeIndex < nodeTotal`
- Throw a clear error message when invalid (include the raw env var values if helpful).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Silent observation loss 🐞 Bug ◔ Observability
Description
scripts/generate-e2e-timings.js swallows all exceptions when fetching/parsing node output, so
expired/unexpected output formats silently reduce the observation set and can cause unexplained
solver failures or low-quality manifests.
Code

scripts/generate-e2e-timings.js[R63-75]

+  for (const action of step.actions) {
+    if (...

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Balance CircleCI e2e parallel nodes using measured per-file timings
✨ Enhancement ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

Walkthroughs

Description
• Replace filesize-based e2e splitting with deterministic, timing-weighted bin packing per node.
• Add a per-file timing manifest derived from historical CI node wall-clock observations.
• Provide a generator script to periodically refresh timings from CircleCI’s public API.
Diagram
graph TD
  config[".circleci/config.yml"] --> ci["CircleCI e2e_test"] --> split["split-e2e-tests.js"] --> mocha["mocha-circleci"]
  split --> timings["e2e-test-timings.json"]
  gen["generate-e2e-timings.js"] --> api{{"CircleCI API"}} --> timings

  subgraph Legend
    direction LR
    _cfg["Config/File"] ~~~ _job["CI Job/Step"] ~~~ _ext{{"External API"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Custom Mocha/JUnit timing reporter (include hook time)
  • ➕ Keeps using CircleCI’s native tests split --split-by=timings with first-class support
  • ➕ Timings naturally update every run without a separately maintained manifest
  • ➖ Non-trivial to correctly attribute before/after hook time to files/suites
  • ➖ Requires maintaining reporter changes and ensuring CI artifacts are always collected
2. Runtime splitting via CircleCI API (latest job timings)
  • ➕ Always uses freshest timing data without committing a large manifest file
  • ➕ Can adapt quickly to new/changed tests
  • ➖ Adds network dependency/variability to every e2e node at runtime
  • ➖ May require auth tokens or hit rate limits; more operational complexity
3. Store per-file timings in repo artifacts (e.g., S3/Cache) and update automatically
  • ➕ Avoids committing a large JSON while still being deterministic per run
  • ➕ Can be updated on main periodically via scheduled workflow
  • ➖ Requires additional infra/credentials and failure-handling around artifact fetch/publish
  • ➖ Harder to debug than a checked-in manifest

Recommendation: The current approach (checked-in manifest + deterministic local bin-packing) is a good fit for reliability and reproducibility: no runtime API calls, stable assignments across nodes, and a clear fallback (median/default weights). The main tradeoff is staleness/maintenance of the manifest; if drift becomes frequent, consider a future follow-up to generate hook-inclusive timings via a Mocha reporter so the data refreshes automatically while still leveraging CircleCI’s built-in splitter.

Grey Divider

File Changes

Other (4)
config.yml Switch e2e sharding from filesize split to timing-based custom splitter +7/-5

Switch e2e sharding from filesize split to timing-based custom splitter

• Replaces 'circleci tests split --split-by=filesize' with 'node scripts/split-e2e-tests.js' for deterministic, timing-weighted sharding across parallel nodes. Updates inline documentation explaining why CircleCI’s built-in timing split is ineffective for this Mocha suite and how to refresh the manifest.

.circleci/config.yml


e2e-test-timings.json Add per-file e2e wall-clock timing manifest +191/-0

Add per-file e2e wall-clock timing manifest

• Introduces a JSON map of e2e test file paths to estimated wall-clock durations (seconds). Used by the splitter to weight bin-packing; files missing from the manifest fall back to a median weight.

scripts/e2e-test-timings.json


generate-e2e-timings.js Add generator to infer per-file timings from CircleCI job history +157/-0

Add generator to infer per-file timings from CircleCI job history

• Adds a Node script that queries CircleCI APIs for recent successful 'e2e_test' jobs, extracts per-node wall time and executed file lists, then solves for per-file durations using a non-negative, regularized coordinate-descent approach. Writes the regenerated estimates to 'scripts/e2e-test-timings.json' and reports mean prediction error and estimated fixed per-node overhead.

scripts/generate-e2e-timings.js


split-e2e-tests.js Add deterministic LPT bin-packing splitter for e2e test files +99/-0

Add deterministic LPT bin-packing splitter for e2e test files

• Adds a Node script that finds all '.e2e*.ts' files, assigns weights from the timing manifest (median fallback for unknown files), and performs deterministic greedy LPT bin packing to balance node loads. Prints either per-node predicted loads ('--stats') or the absolute file list for the current CircleCI node ('CIRCLE_NODE_INDEX').

scripts/split-e2e-tests.js


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 11, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 6317dc9

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 11, 2026

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 11, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8c96876

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 11, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 857e455

1 similar comment
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 11, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 857e455

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 11, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 994a025

Comment on lines +126 to +158
async function main() {
console.error('finding recent successful e2e_test jobs...');
const jobNumbers = await findRecentE2eJobs();
console.error(`collecting node data from ${jobNumbers.length} jobs...`);
const observations = [];
for (const jobNumber of jobNumbers) {
const nodes = await fetchNodeObservations(jobNumber);
observations.push(...nodes);
console.error(` job ${jobNumber}: ${nodes.length} nodes`);
}
if (observations.length < 50) {
throw new Error(`only ${observations.length} node observations collected - not enough to solve reliably`);
}
const files = [...new Set(observations.flatMap((o) => o.files))].sort();
console.error(`solving for ${files.length} files from ${observations.length} node observations...`);
const { estimate, fixed, meanError } = solve(observations, files);
console.error(
`fixed per-node overhead: ${Math.round(fixed)}s, mean node prediction error: ${(meanError * 100).toFixed(1)}%`
);
if (meanError > 0.15) {
console.error('warning: prediction error is high; estimates may be stale or assignments lacked diversity');
}

// floor at 15s: the solver can collapse under-identified files to 0, which makes the
// bin-packer treat them as free and pile dozens of them onto one node
const manifest = Object.fromEntries(files.map((f, i) => [f, Math.max(15, Math.round(estimate[i]))]));
fs.writeFileSync(OUT_FILE, `${JSON.stringify(manifest, null, 2)}\n`);
console.error(`wrote ${OUT_FILE}`);
}

main().catch((err) => {
console.error(err);
process.exit(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Raw output in generate-e2e-timings.js 📘 Rule violation ⚙ Maintainability

The new scripts scripts/generate-e2e-timings.js and scripts/split-e2e-tests.js emit progress,
warning/note, and stats output using raw console.error and
process.stderr.write/process.stdout.write rather than the shared CLI output formatter. This
violates the repository’s CLI Output Style Guide and can lead to inconsistent CLI styling across the
repo.
Agent Prompt
## Issue description
`scripts/generate-e2e-timings.js` and `scripts/split-e2e-tests.js` emit CLI output (progress, warning/note, and stats lines) using raw `console.error` and `process.stderr.write` / `process.stdout.write`. The repo requires CLI output to follow the CLI Output Style Guide and use the shared output formatting toolkit so messaging is consistent.

## Issue Context
The CLI Output Style Guide (`scopes/harmony/cli/cli-output-style-guide.md`) mandates using the shared toolkit from `@teambit/cli` (`scopes/harmony/cli/output-formatter.ts`) for CLI output. This PR introduces new CLI output in both scripts without using that toolkit.

## Fix Focus Areas
- scripts/generate-e2e-timings.js[126-158]
- scripts/split-e2e-tests.js[41-96]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 11, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit ff971f3

Comment on lines +436 to +439
it('should fail when merging with --build due to test failures', () => {
const output = helper.command.mergeLane('dev', '--build --no-squash');
expect(output).to.have.string('Total Snapped: 0');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Unreachable failure assertion 🐞 Bug ≡ Correctness

The test expects helper.command.mergeLane() to return output when the merge fails, but mergeLane()
runs via execSync and throws on non-zero exit so the assertion on the returned output is never
reached. This makes the new regression test fail (or fail to validate the intended failure mode).
Agent Prompt
### Issue description
The test `should fail when merging with --build due to test failures` calls `helper.command.mergeLane(...)` and asserts on its return value. However, `mergeLane()` ultimately uses `execSync` via `runCmd()`, so when the merge fails it throws and never returns a string, making the assertion unreachable.

### Issue Context
`CommandHelper.mergeLane()` delegates to `runCmd()`, which uses `childProcess.execSync(...)` and will throw on non-zero exit codes.

### Fix Focus Areas
- e2e/harmony/lanes/merge-lanes-edge-cases-2.e2e.ts[436-439]

### Recommended fix
Update the test to assert on the failure using one of the existing patterns used elsewhere in this repo:
- Use `helper.general.runWithTryCatch('bit lane merge ...')` (or add a `mergeLaneWithTryCatch` helper) and then assert on the returned output string.
- Or wrap the call in `expect(() => helper.command.mergeLane(...)).to.throw()` and validate the error message/output as needed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 12, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit d1c55ed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant