mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
aeb495bccc
* wip
* context
* cleanup
* wip
* wip
* Update trace-viewer.tsx
* adjust hovers
* time marker
* tweak
* Update event-list.tsx
* header
* Update utils.ts
* search
* Update timeline.tsx
* ship
* Update timeline.tsx
* Update event-list.tsx
* Create icons.tsx
* Update trace-viewer.tsx
* Update utils.ts
* new trace viewrr
* Update workflow-trace-view.tsx
* Fix: Runtime crash when `trace` is `undefined`: `NewTraceViewerComponent` receives `undefined` (cast as `Trace`) and immediately accesses `trace.spans`, causing "Cannot read properties of undefined".
This commit fixes the issue reported at packages/web-shared/src/components/trace-viewer-new.tsx:28
**Bug Analysis:**
In `trace-viewer-new.tsx`, the `buildTrace` function returns `undefined` when `!run?.runId`. The result is stored in `trace` which has type `TraceWithMeta | undefined`. However, on line 28, `trace` is passed directly to `NewTraceViewerComponent` with a type assertion `trace as Trace`, which silences the TypeScript error but does not prevent the runtime crash.
Inside `NewTraceViewerComponent` (in `new-trace-viewer/trace-viewer.tsx` line 98), the component immediately accesses `trace.spans`:
```tsx
<ActiveSpanProvider spans={trace.spans}>
```
When `trace` is `undefined`, this produces: `TypeError: Cannot read properties of undefined (reading 'spans')`.
This happens whenever the component renders before `run.runId` is available — a normal scenario during initial loading.
The old `WorkflowTraceViewer` component (in `workflow-trace-view.tsx` line 953) correctly handles this with a `if (!trace)` guard that renders a loading skeleton. The new component lacks this guard.
**Fix:**
Added a null guard in `trace-viewer-new.tsx` that checks `if (!trace)` before rendering `NewTraceViewerComponent`. When trace is undefined, a simple loading placeholder is rendered instead. This prevents the crash and follows the same pattern as the existing `WorkflowTraceViewer`. The `as Trace` cast on line 28 is now safe because `trace` is guaranteed to be defined after the guard.
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: mitul-s <mitulxshah@gmail.com>
* fixed edge view
* Update timeline.tsx
* ship it
* cleanup
* Update trace-viewer.tsx
* wip
* wip
* Add detail pane to the new trace viewer + cleanup (#1714)
* detail pane
* cleanup
* cleanup
* cleanu
* fix
* Update workflow-trace-view.tsx
* cleanup
* Update entity-detail-panel.tsx
* move sidebar provider into export
* dead code
* cleaning up more
* remove decorative indenting & output loader
* fix bars
* Update inspector-theme.ts
* cleanups
* Update copyable-data-block.tsx
* cleanup
* chonky
* Update timeline.tsx
* bug fxi
* marker lines
* wip
* changes
* Update event-list.tsx
* rounded
* Update event-list.tsx
* middle truncate component
* cleanup
* height updatres
* colour fixes
---------
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
import type { Event, WorkflowRun } from '@workflow/world';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { computeSegments } from '../src/components/trace-viewer/components/span-segments.js';
|
|
import { parseTrace } from '../src/components/trace-viewer/util/tree.js';
|
|
import {
|
|
buildTrace,
|
|
groupEventsByCorrelation,
|
|
} from '../src/lib/trace-builder.js';
|
|
|
|
const BASE_TIME = new Date('2026-03-16T00:00:00Z');
|
|
const STARTED_TIME = new Date('2026-03-16T00:00:01Z');
|
|
const COMPLETED_TIME = new Date('2026-03-16T00:00:10Z');
|
|
|
|
function makeV1Run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
|
|
return {
|
|
runId: 'wrun_v1test',
|
|
deploymentId: 'dep_1',
|
|
workflowName: 'v1-workflow',
|
|
specVersion: 1,
|
|
input: {},
|
|
createdAt: BASE_TIME,
|
|
updatedAt: COMPLETED_TIME,
|
|
startedAt: STARTED_TIME,
|
|
completedAt: COMPLETED_TIME,
|
|
status: 'completed',
|
|
output: { result: 'ok' },
|
|
error: undefined,
|
|
executionContext: {},
|
|
expiredAt: undefined,
|
|
...overrides,
|
|
} as WorkflowRun;
|
|
}
|
|
|
|
/** V2-style step events (includes step_created) */
|
|
function makeStepEvents(
|
|
correlationId: string,
|
|
stepName: string,
|
|
startOffset: number,
|
|
endOffset: number
|
|
): Event[] {
|
|
return [
|
|
{
|
|
eventId: `evnt_${correlationId}_created`,
|
|
runId: 'wrun_v1test',
|
|
eventType: 'step_created',
|
|
correlationId,
|
|
createdAt: new Date(BASE_TIME.getTime() + startOffset),
|
|
specVersion: 1,
|
|
eventData: { stepName, input: {} },
|
|
},
|
|
{
|
|
eventId: `evnt_${correlationId}_started`,
|
|
runId: 'wrun_v1test',
|
|
eventType: 'step_started',
|
|
correlationId,
|
|
createdAt: new Date(BASE_TIME.getTime() + startOffset + 100),
|
|
specVersion: 1,
|
|
},
|
|
{
|
|
eventId: `evnt_${correlationId}_completed`,
|
|
runId: 'wrun_v1test',
|
|
eventType: 'step_completed',
|
|
correlationId,
|
|
createdAt: new Date(BASE_TIME.getTime() + endOffset),
|
|
specVersion: 1,
|
|
eventData: { result: 42 },
|
|
},
|
|
] as Event[];
|
|
}
|
|
|
|
/** V1-style step events (no step_created — only step_started + step_completed) */
|
|
function makeV1StepEvents(
|
|
correlationId: string,
|
|
startOffset: number,
|
|
endOffset: number
|
|
): Event[] {
|
|
return [
|
|
{
|
|
eventId: `evnt_${correlationId}_started`,
|
|
runId: 'wrun_v1test',
|
|
eventType: 'step_started',
|
|
correlationId,
|
|
createdAt: new Date(BASE_TIME.getTime() + startOffset),
|
|
specVersion: 1,
|
|
},
|
|
{
|
|
eventId: `evnt_${correlationId}_completed`,
|
|
runId: 'wrun_v1test',
|
|
eventType: 'step_completed',
|
|
correlationId,
|
|
createdAt: new Date(BASE_TIME.getTime() + endOffset),
|
|
specVersion: 1,
|
|
eventData: { result: 42 },
|
|
},
|
|
] as Event[];
|
|
}
|
|
|
|
describe('Trace viewer with v1 events (no run lifecycle events)', () => {
|
|
describe('groupEventsByCorrelation', () => {
|
|
it('groups step events with no run-level events for v1', () => {
|
|
const events = makeStepEvents('step_1', 'add', 1000, 3000);
|
|
const grouped = groupEventsByCorrelation(events);
|
|
|
|
expect(grouped.runLevelEvents).toHaveLength(0);
|
|
expect(grouped.eventsByStepId.size).toBe(1);
|
|
expect(grouped.eventsByStepId.get('step_1')).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe('buildTrace', () => {
|
|
it('builds a valid trace for a completed v1 run with step events', () => {
|
|
const run = makeV1Run({ status: 'completed' });
|
|
const events = makeStepEvents('step_1', 'add', 1000, 3000);
|
|
const now = new Date('2026-03-16T00:01:00Z');
|
|
const trace = buildTrace(run, events, now);
|
|
|
|
expect(trace.traceId).toBe('wrun_v1test');
|
|
expect(trace.rootSpanId).toBe('wrun_v1test');
|
|
expect(trace.spans).toHaveLength(2);
|
|
|
|
const runSpan = trace.spans.find((s) => s.spanId === 'wrun_v1test');
|
|
expect(runSpan).toBeDefined();
|
|
expect(runSpan!.attributes.resource).toBe('run');
|
|
expect(runSpan!.attributes.data).toMatchObject({
|
|
status: 'completed',
|
|
completedAt: COMPLETED_TIME,
|
|
});
|
|
});
|
|
|
|
it('builds a valid trace for a failed v1 run', () => {
|
|
const run = makeV1Run({
|
|
status: 'failed',
|
|
output: undefined,
|
|
error: { message: 'boom' },
|
|
});
|
|
const events = makeStepEvents('step_1', 'add', 1000, 3000);
|
|
const trace = buildTrace(run, events, new Date());
|
|
|
|
const runSpan = trace.spans.find((s) => s.spanId === 'wrun_v1test');
|
|
expect(runSpan!.attributes.data).toMatchObject({ status: 'failed' });
|
|
});
|
|
|
|
it('builds a valid trace for a v1 run with no events at all', () => {
|
|
const run = makeV1Run({ status: 'completed' });
|
|
const trace = buildTrace(run, [], new Date());
|
|
|
|
expect(trace.spans).toHaveLength(1);
|
|
expect(trace.spans[0].spanId).toBe('wrun_v1test');
|
|
expect(trace.spans[0].attributes.resource).toBe('run');
|
|
});
|
|
|
|
it('builds step spans from v1 events (no step_created)', () => {
|
|
const run = makeV1Run({ status: 'completed' });
|
|
const events = [
|
|
...makeV1StepEvents('step_1', 1000, 3000),
|
|
...makeV1StepEvents('step_2', 4000, 6000),
|
|
];
|
|
const trace = buildTrace(run, events, new Date());
|
|
|
|
// Run span + 2 step spans
|
|
expect(trace.spans).toHaveLength(3);
|
|
|
|
const stepSpans = trace.spans.filter(
|
|
(s) => s.attributes.resource === 'step'
|
|
);
|
|
expect(stepSpans).toHaveLength(2);
|
|
expect(stepSpans[0].spanId).toBe('step_1');
|
|
expect(stepSpans[1].spanId).toBe('step_2');
|
|
});
|
|
|
|
it('derives step status from v1 events without step_created', () => {
|
|
const run = makeV1Run({ status: 'completed' });
|
|
const events = makeV1StepEvents('step_1', 1000, 3000);
|
|
const trace = buildTrace(run, events, new Date());
|
|
|
|
const stepSpan = trace.spans.find((s) => s.spanId === 'step_1');
|
|
expect(stepSpan).toBeDefined();
|
|
expect(stepSpan!.attributes.data).toMatchObject({
|
|
status: 'completed',
|
|
stepName: '',
|
|
});
|
|
});
|
|
|
|
it('uses correlationId for step span when stepName is unavailable', () => {
|
|
const run = makeV1Run({ status: 'completed' });
|
|
const events = makeV1StepEvents('step_1', 1000, 3000);
|
|
const trace = buildTrace(run, events, new Date());
|
|
|
|
const stepSpan = trace.spans.find((s) => s.spanId === 'step_1');
|
|
expect(stepSpan).toBeDefined();
|
|
// Without step_created, stepName is empty; the span name comes from
|
|
// parseStepName which returns the correlationId as fallback
|
|
expect(stepSpan!.spanId).toBe('step_1');
|
|
});
|
|
|
|
it('uses resumeAt for pending sleep span duration', () => {
|
|
const run = makeV1Run({
|
|
status: 'running',
|
|
completedAt: undefined,
|
|
output: undefined,
|
|
});
|
|
const waitCreatedAt = new Date(BASE_TIME.getTime() + 1_000);
|
|
const resumeAt = new Date(BASE_TIME.getTime() + 61_000);
|
|
const events = [
|
|
{
|
|
eventId: 'evnt_wait_created',
|
|
runId: 'wrun_v1test',
|
|
eventType: 'wait_created',
|
|
correlationId: 'wait_1',
|
|
createdAt: waitCreatedAt,
|
|
specVersion: 1,
|
|
eventData: { resumeAt },
|
|
},
|
|
] as Event[];
|
|
|
|
const trace = buildTrace(
|
|
run,
|
|
events,
|
|
new Date(BASE_TIME.getTime() + 11_000)
|
|
);
|
|
const sleepSpan = trace.spans.find((s) => s.resource === 'sleep');
|
|
|
|
expect(sleepSpan).toBeDefined();
|
|
const { map } = parseTrace(trace);
|
|
expect(map[sleepSpan!.spanId].duration).toBe(10_000);
|
|
});
|
|
|
|
it('caps pending sleep spans at the latest known event before resumeAt', () => {
|
|
const run = makeV1Run({
|
|
status: 'completed',
|
|
completedAt: new Date(BASE_TIME.getTime() + 86_400_000),
|
|
});
|
|
const waitCreatedAt = new Date(BASE_TIME.getTime() + 1_000);
|
|
const latestKnownAt = new Date(BASE_TIME.getTime() + 86_401_000);
|
|
const resumeAt = new Date(BASE_TIME.getTime() + 6 * 86_400_000 + 1_000);
|
|
const events = [
|
|
{
|
|
eventId: 'evnt_wait_created',
|
|
runId: 'wrun_v1test',
|
|
eventType: 'wait_created',
|
|
correlationId: 'wait_1',
|
|
createdAt: waitCreatedAt,
|
|
specVersion: 1,
|
|
eventData: { resumeAt },
|
|
},
|
|
{
|
|
eventId: 'evnt_run_completed',
|
|
runId: 'wrun_v1test',
|
|
eventType: 'run_completed',
|
|
createdAt: latestKnownAt,
|
|
specVersion: 1,
|
|
eventData: { output: { result: 'ok' } },
|
|
},
|
|
] as Event[];
|
|
|
|
const trace = buildTrace(run, events, latestKnownAt);
|
|
const sleepSpan = trace.spans.find((s) => s.resource === 'sleep');
|
|
|
|
expect(sleepSpan).toBeDefined();
|
|
const { map } = parseTrace(trace);
|
|
expect(map[sleepSpan!.spanId].duration).toBe(86_399_000);
|
|
});
|
|
});
|
|
|
|
describe('computeSegments for v1 run spans', () => {
|
|
it('shows "succeeded" segment for a completed v1 run (no run_completed event)', () => {
|
|
const run = makeV1Run({ status: 'completed' });
|
|
const events = makeStepEvents('step_1', 'add', 1000, 3000);
|
|
const trace = buildTrace(run, events, new Date());
|
|
const { map } = parseTrace(trace);
|
|
|
|
const runNode = map[run.runId];
|
|
expect(runNode).toBeDefined();
|
|
|
|
const result = computeSegments('run', runNode);
|
|
expect(result.segments.length).toBeGreaterThan(0);
|
|
|
|
const lastSegment = result.segments[result.segments.length - 1];
|
|
expect(lastSegment.status).toBe('succeeded');
|
|
expect(lastSegment.endFraction).toBe(1);
|
|
});
|
|
|
|
it('shows "failed" segment for a failed v1 run (no run_failed event)', () => {
|
|
const run = makeV1Run({
|
|
status: 'failed',
|
|
output: undefined,
|
|
error: { message: 'boom' },
|
|
});
|
|
const events = makeStepEvents('step_1', 'add', 1000, 3000);
|
|
const trace = buildTrace(run, events, new Date());
|
|
const { map } = parseTrace(trace);
|
|
|
|
const runNode = map[run.runId];
|
|
const result = computeSegments('run', runNode);
|
|
|
|
const lastSegment = result.segments[result.segments.length - 1];
|
|
expect(lastSegment.status).toBe('failed');
|
|
expect(lastSegment.endFraction).toBe(1);
|
|
});
|
|
|
|
it('shows "running" segment for an in-progress v1 run', () => {
|
|
const run = makeV1Run({
|
|
status: 'running',
|
|
completedAt: undefined,
|
|
output: undefined,
|
|
});
|
|
const events = makeStepEvents('step_1', 'add', 1000, 3000);
|
|
const now = new Date('2026-03-16T00:01:00Z');
|
|
const trace = buildTrace(run, events, now);
|
|
const { map } = parseTrace(trace);
|
|
|
|
const runNode = map[run.runId];
|
|
const result = computeSegments('run', runNode);
|
|
|
|
const lastSegment = result.segments[result.segments.length - 1];
|
|
expect(lastSegment.status).toBe('running');
|
|
});
|
|
|
|
it('shows queued + succeeded for a v1 run with startedAt', () => {
|
|
const run = makeV1Run({ status: 'completed', startedAt: STARTED_TIME });
|
|
const trace = buildTrace(run, [], new Date());
|
|
const { map } = parseTrace(trace);
|
|
|
|
const runNode = map[run.runId];
|
|
const result = computeSegments('run', runNode);
|
|
|
|
expect(result.segments.length).toBe(2);
|
|
expect(result.segments[0].status).toBe('queued');
|
|
expect(result.segments[1].status).toBe('succeeded');
|
|
});
|
|
|
|
it('v2 baseline: shows "succeeded" from run_completed event', () => {
|
|
const run = makeV1Run({ specVersion: 2, status: 'completed' });
|
|
const stepEvents = makeStepEvents('step_1', 'add', 1000, 3000);
|
|
const runCreatedEvent: Event = {
|
|
eventId: 'evnt_run_created',
|
|
runId: 'wrun_v1test',
|
|
eventType: 'run_created',
|
|
createdAt: BASE_TIME,
|
|
specVersion: 2,
|
|
eventData: {
|
|
deploymentId: 'dep_1',
|
|
workflowName: 'v1-workflow',
|
|
input: {},
|
|
},
|
|
} as Event;
|
|
const runCompletedEvent: Event = {
|
|
eventId: 'evnt_run_completed',
|
|
runId: 'wrun_v1test',
|
|
eventType: 'run_completed',
|
|
createdAt: COMPLETED_TIME,
|
|
specVersion: 2,
|
|
eventData: { output: { result: 'ok' } },
|
|
} as Event;
|
|
const events = [runCreatedEvent, ...stepEvents, runCompletedEvent];
|
|
const trace = buildTrace(run, events, new Date());
|
|
const { map } = parseTrace(trace);
|
|
|
|
const runNode = map[run.runId];
|
|
const result = computeSegments('run', runNode);
|
|
|
|
const lastSegment = result.segments[result.segments.length - 1];
|
|
expect(lastSegment.status).toBe('succeeded');
|
|
});
|
|
|
|
it('v2 mid-pagination: shows "running" when run_completed has not loaded yet', () => {
|
|
const run = makeV1Run({ specVersion: 2, status: 'completed' });
|
|
const stepEvents = makeStepEvents('step_1', 'add', 1000, 3000);
|
|
const runCreatedEvent: Event = {
|
|
eventId: 'evnt_run_created',
|
|
runId: 'wrun_v1test',
|
|
eventType: 'run_created',
|
|
createdAt: BASE_TIME,
|
|
specVersion: 2,
|
|
eventData: {
|
|
deploymentId: 'dep_1',
|
|
workflowName: 'v1-workflow',
|
|
input: {},
|
|
},
|
|
} as Event;
|
|
// run_created is present but run_completed hasn't loaded yet
|
|
const events = [runCreatedEvent, ...stepEvents];
|
|
const trace = buildTrace(run, events, new Date());
|
|
const { map } = parseTrace(trace);
|
|
|
|
const runNode = map[run.runId];
|
|
const result = computeSegments('run', runNode);
|
|
|
|
const lastSegment = result.segments[result.segments.length - 1];
|
|
expect(lastSegment.status).toBe('running');
|
|
});
|
|
});
|
|
});
|