Skip to content

Commit 4e3aafd

Browse files
committed
fix(steps): surface run-scoped per-step error text and stepType
1 parent e53257d commit 4e3aafd

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
@@ -2550,6 +2550,65 @@ describe('runSteps', () => {
25502550
expect(step2.outcomeContributesToFailure).toBe(true);
25512551
});
25522552

2553+
it('--run-id carries the per-step error text and stepType through to JSON (no silent drop)', async () => {
2554+
// Regression lock for the "steps discard RunStepDto.error" gap: the wire
2555+
// already returns the failure text in the same response; it must survive
2556+
// the CliTestStep mapping instead of forcing an artifact-bundle download.
2557+
const { credentialsPath } = makeCreds();
2558+
const runWithStepError = {
2559+
...RUN_WITH_STEPS,
2560+
status: 'failed' as const,
2561+
failedStepIndex: 2,
2562+
steps: [
2563+
RUN_WITH_STEPS.steps[0]!,
2564+
{
2565+
...RUN_WITH_STEPS.steps[1]!,
2566+
status: 'failed',
2567+
error: 'Expected heading "Order confirmed" to be visible, got hidden',
2568+
},
2569+
],
2570+
};
2571+
const fetchImpl = makeFetch(() => ({ body: runWithStepError }));
2572+
const page = await runSteps(
2573+
{ profile: 'default', output: 'json', debug: false, testId: 'test_fe', runId: 'run_scoped' },
2574+
{ credentialsPath, fetchImpl, stdout: () => undefined },
2575+
);
2576+
const passing = page.items.find(s => s.stepIndex === 1)!;
2577+
const failing = page.items.find(s => s.stepIndex === 2)!;
2578+
expect(failing.error).toBe('Expected heading "Order confirmed" to be visible, got hidden');
2579+
expect(failing.stepType).toBe('assertion');
2580+
expect(passing.error).toBeNull();
2581+
expect(passing.stepType).toBe('action');
2582+
});
2583+
2584+
it('--run-id text mode prints an indented error: sub-line under the failed row only', async () => {
2585+
const { credentialsPath } = makeCreds();
2586+
const runWithStepError = {
2587+
...RUN_WITH_STEPS,
2588+
status: 'failed' as const,
2589+
failedStepIndex: 2,
2590+
steps: [
2591+
RUN_WITH_STEPS.steps[0]!,
2592+
{
2593+
...RUN_WITH_STEPS.steps[1]!,
2594+
status: 'failed',
2595+
error: 'Locator resolved to hidden element\n at assert heading',
2596+
},
2597+
],
2598+
};
2599+
const fetchImpl = makeFetch(() => ({ body: runWithStepError }));
2600+
const out: string[] = [];
2601+
await runSteps(
2602+
{ profile: 'default', output: 'text', debug: false, testId: 'test_fe', runId: 'run_scoped' },
2603+
{ credentialsPath, fetchImpl, stdout: line => out.push(line) },
2604+
);
2605+
const block = out.join('\n');
2606+
// Newlines in the wire error collapse to one displayable line.
2607+
expect(block).toContain('error: Locator resolved to hidden element at assert heading');
2608+
// Exactly one sub-line: the passing step must not grow one.
2609+
expect(block.match(/error: /g)).toHaveLength(1);
2610+
});
2611+
25532612
it('--run-id: rejects a runId that belongs to a different test (exit 4)', async () => {
25542613
const { credentialsPath } = makeCreds();
25552614
// 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
@@ -177,6 +177,19 @@ export interface CliTestStep {
177177
* that don't emit the field still type-check.
178178
*/
179179
outcomeContributesToFailure?: boolean | null;
180+
/**
181+
* Per-step failure text, carried from `RunStepDto.error` on the run-scoped
182+
* endpoint (`GET /runs/{id}?includeSteps=true`). Only present on `--run-id`
183+
* responses; the cumulative `/tests/{id}/steps` rows do not carry it, so the
184+
* field stays optional (additive, non-breaking for existing consumers).
185+
*/
186+
error?: string | null;
187+
/**
188+
* Wire step kind from `RunStepDto.type` on the run-scoped endpoint. Same
189+
* availability rules as `error`. Named `stepType` to avoid colliding with
190+
* the free-form `action` label above.
191+
*/
192+
stepType?: 'action' | 'assertion';
180193
}
181194

182195
/**
@@ -3627,6 +3640,12 @@ function mapRunStepToCliTestStep(step: RunStepDto, run: RunResponse): CliTestSte
36273640
// non-contributors. (Per the CliTestStep contract: null ≠ false.)
36283641
outcomeContributesToFailure:
36293642
run.failedStepIndex === null ? null : numericIndex === run.failedStepIndex,
3643+
// Carry the per-step failure text and the wire step kind through instead
3644+
// of dropping them: the agent asking "why did this step fail?" would
3645+
// otherwise have to download the whole artifact bundle to read a string
3646+
// this very response already contained.
3647+
error: step.error,
3648+
stepType: step.type,
36303649
};
36313650
}
36323651

@@ -3947,6 +3966,14 @@ const RUN_HISTORY_TABLE_COL_WIDTHS = {
39473966
*/
39483967
const DESC_COL_MAX = 60;
39493968

3969+
/**
3970+
* Cap, in chars, for the one-line `error:` sub-line under a failed step row
3971+
* in `renderStepsText`. Long enough for a full assertion message, short
3972+
* enough that a stack-trace blob can't flood the table. Full text is in
3973+
* `--output json`.
3974+
*/
3975+
const ERROR_SUBLINE_MAX = 200;
3976+
39503977
/** Max chars to show in the TARGETURL sub-line (excess truncated with …). */
39513978
const HISTORY_TARGET_URL_MAX = 80;
39523979

@@ -8221,16 +8248,29 @@ function renderStepsText(page: Page<CliTestStep>): string {
82218248
' ' +
82228249
'UPDATED';
82238250

8224-
const rows = page.items.map(s => {
8251+
const rows = page.items.flatMap(s => {
82258252
const marker = s.outcomeContributesToFailure === true ? '* ' : ' ';
8226-
return [
8253+
const row = [
82278254
marker,
82288255
pad(String(s.stepIndex), indexWidth),
82298256
pad(s.action, actionWidth),
82308257
pad(s.status ?? '—', statusWidth),
82318258
pad(descOf(s), descWidth),
82328259
s.updatedAt,
82338260
].join(' ');
8261+
// Run-scoped rows carry the per-step failure text; surface it as an
8262+
// indented sub-line under failed rows (mirrors the history table's
8263+
// `targetUrl:` sub-line). Collapsed to one line and capped so a huge
8264+
// stack blob can't wreck the table; full text ships in --output json.
8265+
if (s.status === 'failed' && typeof s.error === 'string' && s.error.length > 0) {
8266+
const oneLine = s.error.replace(/\s+/g, ' ').trim();
8267+
const shown =
8268+
oneLine.length > ERROR_SUBLINE_MAX
8269+
? `${oneLine.slice(0, ERROR_SUBLINE_MAX - 1)}…`
8270+
: oneLine;
8271+
return [row, ` error: ${shown}`];
8272+
}
8273+
return [row];
82348274
});
82358275

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

0 commit comments

Comments
 (0)