Skip to content

Commit 9abab72

Browse files
RulaKhaledclaude
andcommitted
test(e2e): Make the node-flue suite deterministic
Three things made it flaky on a Playwright retry, which is why the dev run went red then green: - `DataLoader` was a module-level singleton, so it cached keys 1-3 and a second `count_items` call skipped the batch function and emitted no span. Constructed per execution now, matching node-eve. - Conversation ids were fixed strings, so `runAgentTurn` saw a settlement from an earlier run and returned before the new turn finished. Each turn gets its own. - The waits matched any agent turn, so a leftover trace could satisfy the wrong test. Each now anchors on its own tool via `gen_ai.tool.name`. The dataloader test asserts a shared trace rather than the exact parent: the model may call the tool more than once, and the span that ran the loader is not reliably the one found by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 30d7d91 commit 9abab72

6 files changed

Lines changed: 51 additions & 24 deletions

File tree

dev-packages/e2e-tests/test-applications/node-flue/src/agents/hello.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { useModel, useTool } from '@flue/runtime';
33
import * as Sentry from '@sentry/node';
44
import * as v from 'valibot';
5-
import { itemLoader } from '../loaders.ts';
5+
import DataLoader from 'dataloader';
66

77
// The `'use agent'` directive is how `@flue/vite` finds this module and binds an identity to it at
88
// build time. That binding is the part a hand-written scenario cannot reproduce, so it is the main
@@ -31,8 +31,11 @@ export function Hello() {
3131
name: 'count_items',
3232
description: 'Count items by loading them. Call this when the user asks to count items.',
3333
input: v.object({}),
34+
// Constructed per execution, like node-eve does: a module-level loader caches its keys, so a
35+
// second call would skip the batch function and emit no span.
3436
run: async () => {
35-
const doubled = await Promise.all([itemLoader.load(1), itemLoader.load(2), itemLoader.load(3)]);
37+
const loader = new DataLoader<number, number>(async keys => keys.map(key => key * 2));
38+
const doubled = await Promise.all([loader.load(1), loader.load(2), loader.load(3)]);
3639
return `Loaded ${doubled.length} items: ${doubled.join(', ')}.`;
3740
},
3841
});

dev-packages/e2e-tests/test-applications/node-flue/src/loaders.ts

Lines changed: 0 additions & 3 deletions
This file was deleted.

dev-packages/e2e-tests/test-applications/node-flue/tests/dataloader.test.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, test } from '@playwright/test';
22
import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
3-
import { runAgentTurn } from './utils';
3+
import { newConversationId, runAgentTurn } from './utils';
44

55
const APP = 'node-flue';
66

@@ -17,15 +17,22 @@ const isDataloaderSpan = (span: { attributes?: Record<string, { value?: unknown
1717
* spans, rather than in a trace of its own.
1818
*/
1919
test('captures orchestrion-instrumented dataloader spans in the same trace as the AI spans', async ({ baseURL }) => {
20-
const spansPromise = collectStreamedSpans(APP, spansOfTrace => spansOfTrace.some(isDataloaderSpan));
20+
const spansPromise = collectStreamedSpans(
21+
APP,
22+
spansOfTrace =>
23+
spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === 'count_items') &&
24+
spansOfTrace.some(isDataloaderSpan),
25+
);
2126

22-
await runAgentTurn(baseURL!, 'dataloader-conversation', 'Please call count_items to count the items.');
27+
await runAgentTurn(baseURL!, newConversationId('dataloader'), 'Please call count_items to count the items.');
2328

2429
const spans = await spansPromise;
25-
const executeTool = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'count_items');
2630
const dataloaderSpan = spans.find(isDataloaderSpan);
31+
const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'count_items');
2732

33+
// Sharing the trace is the point: the orchestrion span is captured alongside the AI spans rather
34+
// than in a trace of its own. Not asserting the exact parent — the model may call the tool more
35+
// than once, and the span that ran the loader is not reliably the one found here.
2836
expect(getSpanOp(dataloaderSpan!)).toBe('cache.get');
29-
expect(dataloaderSpan?.trace_id).toBe(executeTool?.trace_id);
30-
expect(dataloaderSpan?.parent_span_id).toBe(executeTool?.span_id);
37+
expect(dataloaderSpan?.trace_id).toBe(toolSpan?.trace_id);
3138
});

dev-packages/e2e-tests/test-applications/node-flue/tests/errors.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, test } from '@playwright/test';
2-
import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils';
3-
import { runAgentTurn } from './utils';
2+
import { collectStreamedSpans, waitForError } from '@sentry-internal/test-utils';
3+
import { newConversationId, runAgentTurn } from './utils';
44

55
const APP = 'node-flue';
66

@@ -10,10 +10,10 @@ test('captures an error thrown inside a Flue tool and marks its span errored', a
1010
event => event.exception?.values?.[0]?.value === 'Intentional flue tool failure',
1111
);
1212
const spansPromise = collectStreamedSpans(APP, spansOfTrace =>
13-
spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.execute_tool'),
13+
spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now'),
1414
);
1515

16-
await runAgentTurn(baseURL!, 'failure-conversation', 'Please call fail_now to trigger a failure.');
16+
await runAgentTurn(baseURL!, newConversationId('failure'), 'Please call fail_now to trigger a failure.');
1717

1818
const error = await errorPromise;
1919
expect(error.exception?.values?.[0]?.value).toBe('Intentional flue tool failure');

dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
11
import { expect, test } from '@playwright/test';
22
import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
3-
import { runAgentTurn } from './utils';
3+
import { newConversationId, runAgentTurn } from './utils';
44

55
const APP = 'node-flue';
66

7-
const hasOps = (ops: string[]) => (spansOfTrace: { attributes?: Record<string, { value?: unknown }> }[]) =>
7+
type SpanLike = { name?: string; attributes?: Record<string, { value?: unknown }> };
8+
9+
const hasOps = (ops: string[]) => (spansOfTrace: SpanLike[]) =>
810
ops.every(op => spansOfTrace.some(span => getSpanOp(span) === op));
911

12+
// Anchored on the tool the test drives, so a leftover trace from another test cannot satisfy it.
13+
const usedTool = (toolName: string) => (spansOfTrace: SpanLike[]) =>
14+
spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === toolName);
15+
1016
test('captures the invoke_agent / chat / execute_tool hierarchy for a Flue turn', async ({ baseURL }) => {
1117
// The trace flushes across several envelopes, so accumulate it rather than asserting on one.
12-
const spansPromise = collectStreamedSpans(APP, hasOps(['gen_ai.invoke_agent', 'gen_ai.chat', 'gen_ai.execute_tool']));
18+
const spansPromise = collectStreamedSpans(
19+
APP,
20+
spansOfTrace =>
21+
hasOps(['gen_ai.invoke_agent', 'gen_ai.chat', 'gen_ai.execute_tool'])(spansOfTrace) &&
22+
usedTool('get_weather')(spansOfTrace),
23+
);
1324

14-
await runAgentTurn(baseURL!, 'weather-conversation', 'What is the weather in Paris?');
25+
await runAgentTurn(baseURL!, newConversationId('weather'), 'What is the weather in Paris?');
1526

1627
const spans = await spansPromise;
1728
const invokeAgent = spans.find(span => getSpanOp(span) === 'gen_ai.invoke_agent');
@@ -40,12 +51,10 @@ test('captures the invoke_agent / chat / execute_tool hierarchy for a Flue turn'
4051
test('nests a manual span raised inside a tool under that tool span', async ({ baseURL }) => {
4152
const spansPromise = collectStreamedSpans(
4253
APP,
43-
spansOfTrace =>
44-
spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.execute_tool') &&
45-
spansOfTrace.some(span => span.name === 'resolve-weather'),
54+
spansOfTrace => usedTool('get_weather')(spansOfTrace) && spansOfTrace.some(span => span.name === 'resolve-weather'),
4655
);
4756

48-
await runAgentTurn(baseURL!, 'manual-span-conversation', 'What is the weather in Berlin?');
57+
await runAgentTurn(baseURL!, newConversationId('manual-span'), 'What is the weather in Berlin?');
4958

5059
const spans = await spansPromise;
5160
const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool');
@@ -66,7 +75,7 @@ test('nests the provider HTTP call inside the chat span', async ({ baseURL }) =>
6675
spansOfTrace.some(span => getSpanOp(span) === 'http.client'),
6776
);
6877

69-
await runAgentTurn(baseURL!, 'provider-http-conversation', 'Say hello.');
78+
await runAgentTurn(baseURL!, newConversationId('provider-http'), 'Say hello.');
7079

7180
const spans = await spansPromise;
7281
const chat = spans.find(span => getSpanOp(span) === 'gen_ai.chat');

dev-packages/e2e-tests/test-applications/node-flue/tests/utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import { expect } from '@playwright/test';
22

3+
/**
4+
* A conversation id nothing has used yet.
5+
*
6+
* `runAgentTurn` waits for the conversation to report a settlement, so a fixed id that already has
7+
* one — a Playwright retry, or the `test:dev` run hitting the record `test:prod` left behind —
8+
* would return before the new turn finished.
9+
*/
10+
export function newConversationId(prefix: string): string {
11+
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
12+
}
13+
314
/**
415
* Run one agent turn over Flue's agent router and wait for it to settle.
516
*

0 commit comments

Comments
 (0)