Skip to content

Commit f37c6ef

Browse files
authored
Merge pull request #833 from Dave-London/fix/build-turbo-accounting
fix(build): turbo runner now actually reports per-task accounting (#830)
2 parents d698aa8 + fdcce2a commit f37c6ef

3 files changed

Lines changed: 262 additions & 51 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@paretools/build": patch
3+
---
4+
5+
Fix `turbo` tool reporting `passed/failed/cached` all 0 with empty `tasks: []` on modern turbo 2.x output (#830). Turbo 2.x prefixes per-task status lines with `<pkg>:<task>:` (colon) instead of the legacy `<pkg>#<task>:` (hash) the parser was matching, so every task was silently skipped. The parser now accepts both separators, treats the trailing `(duration)` as optional, recognizes `cache bypass` from `--force` runs, and parses turbo 2.x's `ERROR ...` and `Failed:` failure summary lines. The `passed + failed === totalTasks` invariant is now enforced via the summary line as a fallback when per-task lines can't be matched.

packages/server-build/__tests__/turbo.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,155 @@ describe("parseTurboOutput", () => {
190190
});
191191
});
192192

193+
// ---------------------------------------------------------------------------
194+
// Modern turbo 2.x output format (regression coverage for #830)
195+
// ---------------------------------------------------------------------------
196+
197+
// Real captured output from `turbo 2.9.6` for the repro in #830.
198+
// Note: per-task lines use ":" between package and task (not "#"), and the
199+
// status line does NOT include a trailing "(duration)" suffix.
200+
const TURBO_V2_FORCE_BUILD = [
201+
"",
202+
" • Packages in scope: @paretools/shared",
203+
" • Running build in 1 packages",
204+
" • Remote caching disabled, using shared worktree cache",
205+
"",
206+
"@paretools/shared:build: cache bypass, force executing 678fee9a6acfc093",
207+
"@paretools/shared:build: ",
208+
"@paretools/shared:build: > @paretools/[email protected] build /tmp/pkg/shared",
209+
"@paretools/shared:build: > tsc",
210+
"@paretools/shared:build: ",
211+
"",
212+
" Tasks: 1 successful, 1 total",
213+
"Cached: 0 cached, 1 total",
214+
" Time: 1.262s",
215+
].join("\n");
216+
217+
const TURBO_V2_CACHE_HIT = [
218+
"@paretools/shared:build: cache hit, replaying logs 678fee9a6acfc093",
219+
"@paretools/shared:build: ",
220+
"@paretools/shared:build: > tsc",
221+
"",
222+
" Tasks: 1 successful, 1 total",
223+
"Cached: 1 cached, 1 total",
224+
" Time: 59ms >>> FULL TURBO",
225+
].join("\n");
226+
227+
const TURBO_V2_CACHE_MISS = [
228+
"@paretools/shared:build: cache miss, executing 678fee9a6acfc093",
229+
"@paretools/shared:build: > tsc",
230+
"",
231+
" Tasks: 1 successful, 1 total",
232+
"Cached: 0 cached, 1 total",
233+
" Time: 1.5s",
234+
].join("\n");
235+
236+
// Real captured output for a failing build under turbo 2.x: the task status
237+
// line uses ":" but the failure summary lines still use "#".
238+
const TURBO_V2_FAILURE = [
239+
"@paretools/shared:build: cache bypass, force executing 5a7ec1ce892dbcf0",
240+
"@paretools/shared:build: > tsc",
241+
"@paretools/shared:build: src/break.ts(1,6): error TS1005: ';' expected.",
242+
"@paretools/shared:build: ELIFECYCLE Command failed with exit code 2.",
243+
" ERROR @paretools/shared#build: command (/tmp/pkg/shared) /usr/local/bin/pnpm run build exited (2)",
244+
"",
245+
" Tasks: 0 successful, 1 total",
246+
"Cached: 0 cached, 1 total",
247+
" Time: 1.393s ",
248+
"Failed: @paretools/shared#build",
249+
"",
250+
" ERROR run failed: command exited (2)",
251+
].join("\n");
252+
253+
describe("parseTurboOutput (turbo 2.x)", () => {
254+
it("parses a force-rebuilt single task (cache bypass) and reports passed=1", () => {
255+
const result = parseTurboOutput(TURBO_V2_FORCE_BUILD, "", 0, 1.3);
256+
257+
expect(result.success).toBe(true);
258+
expect(result.totalTasks).toBe(1);
259+
expect(result.passed).toBe(1);
260+
expect(result.failed).toBe(0);
261+
expect(result.cached).toBe(0);
262+
expect(result.tasks).toHaveLength(1);
263+
expect(result.tasks?.[0]).toMatchObject({
264+
package: "@paretools/shared",
265+
task: "build",
266+
status: "pass",
267+
cache: "miss",
268+
});
269+
});
270+
271+
it("parses a cache hit and reports cached=1", () => {
272+
const result = parseTurboOutput(TURBO_V2_CACHE_HIT, "", 0, 0.06);
273+
274+
expect(result.success).toBe(true);
275+
expect(result.totalTasks).toBe(1);
276+
expect(result.passed).toBe(1);
277+
expect(result.failed).toBe(0);
278+
expect(result.cached).toBe(1);
279+
expect(result.tasks?.[0]).toMatchObject({
280+
package: "@paretools/shared",
281+
task: "build",
282+
status: "pass",
283+
cache: "hit",
284+
});
285+
});
286+
287+
it("parses a cache miss task with no inline duration", () => {
288+
const result = parseTurboOutput(TURBO_V2_CACHE_MISS, "", 0, 1.5);
289+
290+
expect(result.success).toBe(true);
291+
expect(result.totalTasks).toBe(1);
292+
expect(result.passed).toBe(1);
293+
expect(result.failed).toBe(0);
294+
expect(result.cached).toBe(0);
295+
expect(result.tasks?.[0]).toMatchObject({
296+
package: "@paretools/shared",
297+
task: "build",
298+
status: "pass",
299+
cache: "miss",
300+
});
301+
});
302+
303+
it("parses a failing build using ERROR + Failed lines (turbo 2.x)", () => {
304+
const result = parseTurboOutput(TURBO_V2_FAILURE, "", 1, 1.4);
305+
306+
expect(result.success).toBe(false);
307+
expect(result.totalTasks).toBe(1);
308+
expect(result.passed).toBe(0);
309+
expect(result.failed).toBe(1);
310+
expect(result.tasks).toHaveLength(1);
311+
expect(result.tasks?.[0]).toMatchObject({
312+
package: "@paretools/shared",
313+
task: "build",
314+
status: "fail",
315+
});
316+
});
317+
318+
it("preserves the passed + failed === totalTasks invariant across all fixtures", () => {
319+
const fixtures = [
320+
{ stdout: TURBO_SUCCESS_ALL_CACHED, exit: 0 },
321+
{ stdout: TURBO_SUCCESS_PARTIAL_CACHE, exit: 0 },
322+
{ stdout: TURBO_FAILURE, exit: 1 },
323+
{ stdout: TURBO_NO_TASKS, exit: 0 },
324+
{ stdout: TURBO_MIXED_OUTPUT, exit: 0 },
325+
{ stdout: TURBO_ERROR_OUTPUT, exit: 1 },
326+
{ stdout: TURBO_V2_FORCE_BUILD, exit: 0 },
327+
{ stdout: TURBO_V2_CACHE_HIT, exit: 0 },
328+
{ stdout: TURBO_V2_CACHE_MISS, exit: 0 },
329+
{ stdout: TURBO_V2_FAILURE, exit: 1 },
330+
];
331+
for (const f of fixtures) {
332+
const result = parseTurboOutput(f.stdout, "", f.exit, 0);
333+
expect(
334+
result.passed + result.failed,
335+
`passed(${result.passed}) + failed(${result.failed}) must equal totalTasks(${result.totalTasks}) for fixture: ${f.stdout.split("\n")[0] || "<empty>"}`,
336+
).toBe(result.totalTasks);
337+
expect(result.cached).toBeLessThanOrEqual(result.totalTasks);
338+
}
339+
});
340+
});
341+
193342
// ---------------------------------------------------------------------------
194343
// Formatter tests
195344
// ---------------------------------------------------------------------------

packages/server-build/src/lib/parsers.ts

Lines changed: 108 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -591,23 +591,37 @@ function parseWebpackText(
591591
// turbo
592592
// ---------------------------------------------------------------------------
593593

594-
// Turbo task line format: <package>#<task>
595-
// e.g. "@paretools/shared#build: cache hit, replaying logs abc123 (100ms)"
596-
// or "@paretools/git#build: cache miss, executing abc123 (2.5s)"
597-
// or "web#build: computed xyz ... (1.2s)"
598-
// Summary line: "Tasks: 5 successful, 5 total"
599-
// Cached line: "Cached: 3 cached, 5 total"
600-
// Duration line:"Duration: 2.5s"
601-
602-
// Match task status lines like:
603-
// @paretools/shared#build: cache hit, replaying logs ... (100ms)
604-
// @scope/pkg#test: cache miss, executing ... (2.5s)
605-
// myapp#lint: cache bypass (5.1s)
606-
const TURBO_TASK_RE = /^(.+?)#(\S+):\s+cache\s+(hit|miss|bypass)(?:,\s+\w+[^(]*)?\(([^)]+)\)/;
607-
608-
// Match task failure lines:
609-
// @scope/pkg#build: command ... exited (1)
610-
// pkg#test: ERROR ... (500ms)
594+
// Turbo task line format: <package>:<task> (modern turbo 2.x) or <package>#<task> (legacy).
595+
// Modern turbo (2.x) prefixes log lines with "<pkg>:<task>:" and emits the
596+
// status line as part of the same prefix:
597+
// "@paretools/shared:build: cache hit, replaying logs abc123"
598+
// "@paretools/shared:build: cache miss, executing abc123"
599+
// "@paretools/shared:build: cache bypass, force executing abc123"
600+
// Older turbo versions used "#" and frequently appended a "(duration)" suffix:
601+
// "@scope/pkg#test: cache miss, executing abc123 (2.5s)"
602+
// "myapp#lint: cache bypass (5.1s)"
603+
// Failure summary lines (still emitted with "#" today):
604+
// " ERROR @scope/pkg#build: command (...) ... exited (1)"
605+
// " Failed: @scope/pkg#build"
606+
// Summary lines:
607+
// "Tasks: 5 successful, 5 total"
608+
// "Cached: 3 cached, 5 total"
609+
610+
// Match per-task status lines. Accepts either ":" or "#" between package and
611+
// task name and treats the trailing "(duration)" as optional, so it works for
612+
// both legacy and modern turbo CLIs.
613+
const TURBO_TASK_RE = /^(.+?)[#:](\S+):\s+cache\s+(hit|miss|bypass)(?:,[^(]*)?(?:\(([^)]+)\))?/;
614+
615+
// Failure lines from the per-task ERROR summary that turbo prints near the end
616+
// of a failed run. Modern turbo emits:
617+
// " ERROR @scope/pkg#build: command (...) ... exited (2)"
618+
// and a one-per-line "Failed:" listing:
619+
// " Failed: @scope/pkg#build"
620+
const TURBO_ERROR_LINE_RE = /(?:^|\s)ERROR\s+(.+?)#(\S+):.*exited\s*\(\d+\)/;
621+
const TURBO_FAILED_LIST_RE = /^\s*Failed:\s+(.+?)#(\S+)\s*$/;
622+
// Legacy/inline task failure lines:
623+
// "@scope/pkg#build: command ... exited (1)"
624+
// "pkg#test: ERROR ... (500ms)"
611625
const TURBO_TASK_FAIL_RE = /^(.+?)#(\S+):.*(?:exited\s*\(\d+\)|ERROR)/;
612626

613627
// Summary lines
@@ -645,57 +659,83 @@ export function parseTurboOutput(
645659
duration: number,
646660
summaryJsonContent?: string,
647661
): TurboResult {
648-
const tasks: TurboTask[] = [];
649-
const seenTasks = new Set<string>();
662+
const taskMap = new Map<string, TurboTask>();
650663
const combined = stdout + "\n" + stderr;
651664
const lines = combined.split("\n");
652665

653666
let summaryTotal = 0;
667+
let summarySuccessful = 0;
654668
let summaryCached = 0;
669+
let sawSummaryLine = false;
670+
671+
const upsertTask = (pkg: string, task: string, patch: Partial<TurboTask>) => {
672+
const key = `${pkg}#${task}`;
673+
const existing = taskMap.get(key);
674+
if (existing) {
675+
// Failure information takes precedence over a previously-seen success line.
676+
if (patch.status === "fail") existing.status = "fail";
677+
if (patch.cache !== undefined && existing.cache === undefined) existing.cache = patch.cache;
678+
if (patch.duration !== undefined && existing.duration === undefined)
679+
existing.duration = patch.duration;
680+
if (patch.durationMs !== undefined && existing.durationMs === undefined)
681+
existing.durationMs = patch.durationMs;
682+
return;
683+
}
684+
taskMap.set(key, {
685+
package: pkg,
686+
task: task,
687+
status: patch.status ?? "pass",
688+
duration: patch.duration,
689+
durationMs: patch.durationMs,
690+
cache: patch.cache,
691+
});
692+
};
655693

656694
for (const line of lines) {
657695
const trimmed = line.trim();
658696

659-
// Check for task status lines
697+
// Check for failure lines first so the ERROR/Failed prefix wins over any
698+
// earlier "cache bypass" status line for the same task.
699+
const errorMatch = trimmed.match(TURBO_ERROR_LINE_RE);
700+
if (errorMatch) {
701+
upsertTask(errorMatch[1], errorMatch[2], { status: "fail", cache: "miss" });
702+
continue;
703+
}
704+
705+
const failedListMatch = trimmed.match(TURBO_FAILED_LIST_RE);
706+
if (failedListMatch) {
707+
upsertTask(failedListMatch[1], failedListMatch[2], { status: "fail", cache: "miss" });
708+
continue;
709+
}
710+
711+
// Per-task status line (covers both legacy "#" and modern ":" separators).
660712
const taskMatch = trimmed.match(TURBO_TASK_RE);
661713
if (taskMatch) {
662-
const key = `${taskMatch[1]}#${taskMatch[2]}`;
663-
if (!seenTasks.has(key)) {
664-
seenTasks.add(key);
665-
const durationStr = taskMatch[4];
666-
const durationMs = parseDurationToMs(durationStr);
667-
tasks.push({
668-
package: taskMatch[1],
669-
task: taskMatch[2],
670-
status: "pass",
671-
duration: durationStr,
672-
durationMs,
673-
cache: taskMatch[3] === "hit" ? "hit" : "miss",
674-
});
675-
}
714+
const durationStr = taskMatch[4];
715+
const durationMs = durationStr ? parseDurationToMs(durationStr) : undefined;
716+
upsertTask(taskMatch[1], taskMatch[2], {
717+
status: "pass",
718+
duration: durationStr,
719+
durationMs,
720+
// Treat both "miss" and "bypass" as a cache miss for accounting.
721+
cache: taskMatch[3] === "hit" ? "hit" : "miss",
722+
});
676723
continue;
677724
}
678725

679-
// Check for failure lines
726+
// Inline failure line (legacy turbo format).
680727
const failMatch = trimmed.match(TURBO_TASK_FAIL_RE);
681728
if (failMatch) {
682-
const key = `${failMatch[1]}#${failMatch[2]}`;
683-
if (!seenTasks.has(key)) {
684-
seenTasks.add(key);
685-
tasks.push({
686-
package: failMatch[1],
687-
task: failMatch[2],
688-
status: "fail",
689-
cache: "miss",
690-
});
691-
}
729+
upsertTask(failMatch[1], failMatch[2], { status: "fail", cache: "miss" });
692730
continue;
693731
}
694732

695-
// Check for summary lines
733+
// Summary lines
696734
const summaryMatch = trimmed.match(TURBO_TASKS_SUMMARY_RE);
697735
if (summaryMatch) {
736+
summarySuccessful = parseInt(summaryMatch[1], 10);
698737
summaryTotal = parseInt(summaryMatch[2], 10);
738+
sawSummaryLine = true;
699739
continue;
700740
}
701741

@@ -705,10 +745,27 @@ export function parseTurboOutput(
705745
}
706746
}
707747

708-
const passed = tasks.filter((t) => t.status === "pass").length;
709-
const failed = tasks.filter((t) => t.status === "fail").length;
710-
const cached = tasks.filter((t) => t.cache === "hit").length;
711-
const totalTasks = summaryTotal || tasks.length;
748+
const tasks = Array.from(taskMap.values());
749+
750+
// Prefer the explicit summary line counts when available — they are the
751+
// ground truth from turbo and let us preserve the
752+
// `passed + failed === totalTasks` invariant even if individual per-task
753+
// status lines couldn't be matched (e.g. log truncation, future format
754+
// changes).
755+
let passed = tasks.filter((t) => t.status === "pass").length;
756+
let failed = tasks.filter((t) => t.status === "fail").length;
757+
let totalTasks = tasks.length;
758+
if (sawSummaryLine) {
759+
totalTasks = summaryTotal;
760+
passed = summarySuccessful;
761+
failed = Math.max(0, summaryTotal - summarySuccessful);
762+
}
763+
764+
// Cached: prefer the summary "Cached: N cached" line; fall back to counting
765+
// per-task hits.
766+
const cachedFromTasks = tasks.filter((t) => t.cache === "hit").length;
767+
const cached = sawSummaryLine ? summaryCached : cachedFromTasks;
768+
712769
let summary: Record<string, unknown> | undefined;
713770
if (summaryJsonContent) {
714771
try {
@@ -728,7 +785,7 @@ export function parseTurboOutput(
728785
totalTasks,
729786
passed,
730787
failed,
731-
cached: summaryCached || cached,
788+
cached,
732789
summary,
733790
};
734791
}

0 commit comments

Comments
 (0)