Files
vercel__workflow/packages/web-shared/test/span-detail-merge.test.ts
Mitul Shah 8393716687 Fix trace detail panel Input/Output flicker (selection-driven state machine) (#2637)
* Add selection-driven span-detail primitives

Extract the run/step/hook/sleep fetch+hydrate core out of useWorkflowResourceData
into a plain async fetchSpanDetailResource (no React state), and add a
selection-driven state machine in web-shared:

- deriveSpanDetailView / resourceNeedsFetchedDetail: pure view-model deriver
  whose status (idle/loading/ready/error) is a function of (selection, fetched
  detail), so it can never lag the selection.
- useSelectedSpanDetail: fetches a selected span's detail directly with a
  request-token to drop stale/out-of-order responses.

* Drive trace detail panel from the span-detail state machine

Replace the cross-package selection round-trip (EntityDetailPanel useEffect ->
onSpanSelect -> page spanSelection state -> useWorkflowResourceData -> context)
with a single injected fetchSpanDetail capability:

- EntityDetailPanel consumes useSelectedSpanDetail; its loading state now stays
  in phase with the selected span, so Input/Output no longer vanish and pop back
  in while navigating.
- SidebarDataContext drops spanDetailData/Loading/Error + onSpanSelect for a
  single fetchSpanDetail; RunDetailView injects it and drops the duplicate
  spanSelection state.
- WorkflowTraceViewer / RunTraceView take fetchSpanDetail too.

* Test span-detail view-model transitions; add changeset

Cover deriveSpanDetailView (idle/loading/ready/error, stale-detail rejection,
hooks ready inline) and resourceNeedsFetchedDetail.

* Trim redundant/narrative comments in span-detail state machine

Comment-only cleanup: drop PR-narration and cross-file duplication from the
deriveSpanDetailView / useSelectedSpanDetail / EntityDetailPanel / fetchSpanDetail
doc comments, keeping the non-obvious intent (request-token, error scoping,
decrypt closure).

* delete pointless coments

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-06-26 16:42:47 -04:00

205 lines
7.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
deriveSpanDetailView,
mergeSpanDetail,
resourceNeedsFetchedDetail,
spanDetailMatchesSelection,
} from '../src/components/sidebar/span-detail-merge.js';
describe('mergeSpanDetail', () => {
const spanStep = {
stepId: 'step_a',
runId: 'wrun_1',
createdAt: new Date('2026-06-05T16:55:18.569Z'),
startedAt: new Date('2026-06-05T16:55:18.812Z'),
completedAt: new Date('2026-06-05T16:55:18.913Z'),
};
const fetchedStep = {
stepId: 'step_a',
runId: 'wrun_1',
createdAt: new Date('2026-06-05T16:55:18.571Z'),
startedAt: new Date('2026-06-05T16:55:18.820Z'),
completedAt: new Date('2026-06-05T16:55:18.911Z'),
output: { ok: true },
};
it('keeps span-derived timestamps when the fetched detail swaps in', () => {
const merged = mergeSpanDetail(spanStep, fetchedStep) as Record<
string,
unknown
>;
expect(merged.createdAt).toBe(spanStep.createdAt);
expect(merged.startedAt).toBe(spanStep.startedAt);
expect(merged.completedAt).toBe(spanStep.completedAt);
});
it('takes non-timestamp fields from the fetched detail', () => {
const merged = mergeSpanDetail(spanStep, fetchedStep) as Record<
string,
unknown
>;
expect(merged.output).toEqual({ ok: true });
});
it('fills timestamps missing from the span data with fetched values', () => {
const { startedAt: _ignored, ...spanWithoutStartedAt } = spanStep;
const merged = mergeSpanDetail(spanWithoutStartedAt, fetchedStep) as Record<
string,
unknown
>;
expect(merged.startedAt).toBe(fetchedStep.startedAt);
});
it('returns span data when there is no fetched detail', () => {
expect(mergeSpanDetail(spanStep, null)).toBe(spanStep);
expect(mergeSpanDetail(spanStep, undefined)).toBe(spanStep);
});
it('returns fetched detail when there is no span data', () => {
expect(mergeSpanDetail(null, fetchedStep)).toBe(fetchedStep);
expect(mergeSpanDetail(undefined, fetchedStep)).toBe(fetchedStep);
});
});
describe('spanDetailMatchesSelection', () => {
const step = { stepId: 'step_a', runId: 'wrun_1' };
const hook = { hookId: 'hook_a', runId: 'wrun_1', token: 'tok' };
const run = { runId: 'wrun_1', workflowName: 'workflows/main' };
const wait = { waitId: 'wait_a', runId: 'wrun_1' };
it('matches a detail to its own resource and id', () => {
expect(spanDetailMatchesSelection(step, 'step', 'step_a')).toBe(true);
expect(spanDetailMatchesSelection(hook, 'hook', 'hook_a')).toBe(true);
expect(spanDetailMatchesSelection(wait, 'sleep', 'wait_a')).toBe(true);
expect(spanDetailMatchesSelection(run, 'run', 'wrun_1')).toBe(true);
});
it('rejects a stale step detail while a hook is selected', () => {
// Navigating from a step onto a hook leaves the step detail set for a
// frame; it must not match the hook (would union their fields).
expect(spanDetailMatchesSelection(step, 'hook', 'hook_a')).toBe(false);
});
it('rejects a detail whose id differs from the current selection', () => {
expect(spanDetailMatchesSelection(step, 'step', 'step_b')).toBe(false);
expect(spanDetailMatchesSelection(hook, 'hook', 'hook_b')).toBe(false);
});
it('rejects child resources for a run selection despite their runId', () => {
expect(spanDetailMatchesSelection(step, 'run', 'wrun_1')).toBe(false);
expect(spanDetailMatchesSelection(hook, 'run', 'wrun_1')).toBe(false);
expect(spanDetailMatchesSelection(wait, 'run', 'wrun_1')).toBe(false);
});
it('returns false for missing detail, resource, or id', () => {
expect(spanDetailMatchesSelection(null, 'step', 'step_a')).toBe(false);
expect(spanDetailMatchesSelection(step, undefined, 'step_a')).toBe(false);
expect(spanDetailMatchesSelection(step, 'step', undefined)).toBe(false);
});
});
describe('resourceNeedsFetchedDetail', () => {
it('is true for resources whose input/output loads lazily', () => {
expect(resourceNeedsFetchedDetail('run')).toBe(true);
expect(resourceNeedsFetchedDetail('step')).toBe(true);
expect(resourceNeedsFetchedDetail('sleep')).toBe(true);
});
it('is false for hooks (rendered from inline data) and unknowns', () => {
expect(resourceNeedsFetchedDetail('hook')).toBe(false);
expect(resourceNeedsFetchedDetail('event')).toBe(false);
expect(resourceNeedsFetchedDetail(undefined)).toBe(false);
});
});
describe('deriveSpanDetailView', () => {
const inlineStep = { stepId: 'step_a', runId: 'wrun_1', status: 'completed' };
const fetchedStep = {
stepId: 'step_a',
runId: 'wrun_1',
output: { ok: true },
};
it('is idle when nothing is selected', () => {
const view = deriveSpanDetailView({
resource: undefined,
resourceId: undefined,
inlineData: undefined,
fetchedDetail: null,
fetchedError: null,
});
expect(view.status).toBe('idle');
});
it('is loading right after selection, before the detail arrives', () => {
const view = deriveSpanDetailView({
resource: 'step',
resourceId: 'step_a',
inlineData: inlineStep,
fetchedDetail: null,
fetchedError: null,
});
expect(view.status).toBe('loading');
// Inline data shows immediately; the fetched output is not present yet.
expect(view.displayData.status).toBe('completed');
expect(view.displayData.output).toBeUndefined();
});
it('is ready once a matching detail arrives, merging input/output', () => {
const view = deriveSpanDetailView({
resource: 'step',
resourceId: 'step_a',
inlineData: inlineStep,
fetchedDetail: fetchedStep,
fetchedError: null,
});
expect(view.status).toBe('ready');
expect(view.detail).toBe(fetchedStep);
expect(view.displayData.output).toEqual({ ok: true });
// Inline status stays authoritative over the merged result.
expect(view.displayData.status).toBe('completed');
});
it('stays loading when the held detail belongs to a different span (stale)', () => {
// The detail for a previously selected step lingers for a frame after
// navigating to a new step — it must be rejected, not merged.
const view = deriveSpanDetailView({
resource: 'step',
resourceId: 'step_b',
inlineData: { stepId: 'step_b', runId: 'wrun_1' },
fetchedDetail: fetchedStep,
fetchedError: null,
});
expect(view.status).toBe('loading');
expect(view.detail).toBeNull();
expect(view.displayData.output).toBeUndefined();
});
it('is ready immediately for hooks (no fetch step)', () => {
const inlineHook = { hookId: 'hook_a', runId: 'wrun_1', token: 'tok' };
const view = deriveSpanDetailView({
resource: 'hook',
resourceId: 'hook_a',
inlineData: inlineHook,
fetchedDetail: null,
fetchedError: null,
});
expect(view.status).toBe('ready');
expect(view.displayData.token).toBe('tok');
});
it('surfaces an error for the current selection', () => {
const error = new Error('not found');
const view = deriveSpanDetailView({
resource: 'step',
resourceId: 'step_a',
inlineData: inlineStep,
fetchedDetail: null,
fetchedError: error,
});
expect(view.status).toBe('error');
expect(view.error).toBe(error);
});
});