Skip to content

Commit dcbcbee

Browse files
authored
fix(steps): surface run-scoped per-step error text and stepType (#167)
1 parent 3fd703f commit dcbcbee

2 files changed

Lines changed: 101 additions & 2 deletions

File tree

src/commands/test.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2603,6 +2603,65 @@ describe('runSteps', () => {
26032603
expect(step2.outcomeContributesToFailure).toBe(true);
26042604
});
26052605

2606+
it('--run-id carries the per-step error text and stepType through to JSON (no silent drop)', async () => {
2607+
// Regression lock for the "steps discard RunStepDto.error" gap: the wire
2608+
// already returns the failure text in the same response; it must survive
2609+
// the CliTestStep mapping instead of forcing an artifact-bundle download.
2610+
const { credentialsPath } = makeCreds();
2611+
const runWithStepError = {
2612+
...RUN_WITH_STEPS,
2613+
status: 'failed' as const,
2614+
failedStepIndex: 2,
2615+
steps: [
2616+
RUN_WITH_STEPS.steps[0]!,
2617+
{
2618+
...RUN_WITH_STEPS.steps[1]!,
2619+
status: 'failed',
2620+
error: 'Expected heading "Order confirmed" to be visible, got hidden',
2621+
},
2622+
],
2623+
};
2624+
const fetchImpl = makeFetch(() => ({ body: runWithStepError }));
2625+
const page = await runSteps(
2626+
{ profile: 'default', output: 'json', debug: false, testId: 'test_fe', runId: 'run_scoped' },
2627+
{ credentialsPath, fetchImpl, stdout: () => undefined },
2628+
);
2629+
const passing = page.items.find(s => s.stepIndex === 1)!;
2630+
const failing = page.items.find(s => s.stepIndex === 2)!;
2631+
expect(failing.error).toBe('Expected heading "Order confirmed" to be visible, got hidden');
2632+
expect(failing.stepType).toBe('assertion');
2633+
expect(passing.error).toBeNull();
2634+
expect(passing.stepType).toBe('action');
2635+
});
2636+
2637+
it('--run-id text mode prints an indented error: sub-line under the failed row only', async () => {
2638+
const { credentialsPath } = makeCreds();
2639+
const runWithStepError = {
2640+
...RUN_WITH_STEPS,
2641+
status: 'failed' as const,
2642+
failedStepIndex: 2,
2643+
steps: [
2644+
RUN_WITH_STEPS.steps[0]!,
2645+
{
2646+
...RUN_WITH_STEPS.steps[1]!,
2647+
status: 'failed',
2648+
error: 'Locator resolved to hidden element\n at assert heading',
2649+
},
2650+
],
2651+
};
2652+
const fetchImpl = makeFetch(() => ({ body: runWithStepError }));
2653+
const out: string[] = [];
2654+
await runSteps(
2655+
{ profile: 'default', output: 'text', debug: false, testId: 'test_fe', runId: 'run_scoped' },
2656+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
2657+
);
2658+
const block = out.join('\n');
2659+
// Newlines in the wire error collapse to one displayable line.
2660+
expect(block).toContain('error: Locator resolved to hidden element at assert heading');
2661+
// Exactly one sub-line: the passing step must not grow one.
2662+
expect(block.match(/error: /g)).toHaveLength(1);
2663+
});
2664+
26062665
it('--run-id: rejects a runId that belongs to a different test (exit 4)', async () => {
26072666
const { credentialsPath } = makeCreds();
26082667
// The run-scoped endpoint returns a run whose testId differs from the

src/commands/test.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,19 @@ export interface CliTestStep {
186186
* that don't emit the field still type-check.
187187
*/
188188
outcomeContributesToFailure?: boolean | null;
189+
/**
190+
* Per-step failure text, carried from `RunStepDto.error` on the run-scoped
191+
* endpoint (`GET /runs/{id}?includeSteps=true`). Only present on `--run-id`
192+
* responses; the cumulative `/tests/{id}/steps` rows do not carry it, so the
193+
* field stays optional (additive, non-breaking for existing consumers).
194+
*/
195+
error?: string | null;
196+
/**
197+
* Wire step kind from `RunStepDto.type` on the run-scoped endpoint. Same
198+
* availability rules as `error`. Named `stepType` to avoid colliding with
199+
* the free-form `action` label above.
200+
*/
201+
stepType?: 'action' | 'assertion';
189202
}
190203

191204
/**
@@ -3642,6 +3655,12 @@ function mapRunStepToCliTestStep(step: RunStepDto, run: RunResponse): CliTestSte
36423655
// non-contributors. (Per the CliTestStep contract: null ≠ false.)
36433656
outcomeContributesToFailure:
36443657
run.failedStepIndex === null ? null : numericIndex === run.failedStepIndex,
3658+
// Carry the per-step failure text and the wire step kind through instead
3659+
// of dropping them: the agent asking "why did this step fail?" would
3660+
// otherwise have to download the whole artifact bundle to read a string
3661+
// this very response already contained.
3662+
error: step.error,
3663+
stepType: step.type,
36453664
};
36463665
}
36473666

@@ -3970,6 +3989,14 @@ const RUN_HISTORY_TABLE_COL_WIDTHS = {
39703989
*/
39713990
const DESC_COL_MAX = 60;
39723991

3992+
/**
3993+
* Cap, in chars, for the one-line `error:` sub-line under a failed step row
3994+
* in `renderStepsText`. Long enough for a full assertion message, short
3995+
* enough that a stack-trace blob can't flood the table. Full text is in
3996+
* `--output json`.
3997+
*/
3998+
const ERROR_SUBLINE_MAX = 200;
3999+
39734000
/** Max chars to show in the TARGETURL sub-line (excess truncated with …). */
39744001
const HISTORY_TARGET_URL_MAX = 80;
39754002

@@ -8433,16 +8460,29 @@ function renderStepsText(page: Page<CliTestStep>): string {
84338460
' ' +
84348461
'UPDATED';
84358462

8436-
const rows = page.items.map(s => {
8463+
const rows = page.items.flatMap(s => {
84378464
const marker = s.outcomeContributesToFailure === true ? '* ' : ' ';
8438-
return [
8465+
const row = [
84398466
marker,
84408467
pad(String(s.stepIndex), indexWidth),
84418468
pad(s.action, actionWidth),
84428469
pad(s.status ?? '—', statusWidth),
84438470
pad(descOf(s), descWidth),
84448471
s.updatedAt,
84458472
].join(' ');
8473+
// Run-scoped rows carry the per-step failure text; surface it as an
8474+
// indented sub-line under failed rows (mirrors the history table's
8475+
// `targetUrl:` sub-line). Collapsed to one line and capped so a huge
8476+
// stack blob can't wreck the table; full text ships in --output json.
8477+
if (s.status === 'failed' && typeof s.error === 'string' && s.error.length > 0) {
8478+
const oneLine = s.error.replace(/\s+/g, ' ').trim();
8479+
const shown =
8480+
oneLine.length > ERROR_SUBLINE_MAX
8481+
? `${oneLine.slice(0, ERROR_SUBLINE_MAX - 1)}…`
8482+
: oneLine;
8483+
return [row, ` error: ${shown}`];
8484+
}
8485+
return [row];
84468486
});
84478487

84488488
const lines: string[] = [header, ...rows, ''];

0 commit comments

Comments
 (0)