Skip to content

Commit ad230f4

Browse files
authored
ref(e2e): migrate deno scenarios from smoke to e2e harness (#1641)
Add `runDenoScenarioDir()` to the e2e scenario harness so Deno tests can run alongside Node/tsx scenarios under the same mock-server infrastructure. Remove the standalone `deno-node` and `deno-browser` smoke scenarios and the legacy `js/smoke/tests/deno/deno.json` config, which are superseded by `e2e/scenarios/deno-node/` and `e2e/scenarios/deno-browser/`. - Refactor `runProcess` to accept an explicit `command` arg so it can spawn arbitrary executables (not just `process.execPath`) - Add `runDenoScenarioDir()` that invokes `deno test --no-check` with the standard harness env vars; defaults entry to `runner.case.ts` - Expose `runDenoScenarioDir` on the `ScenarioHarness` interface and wire it through `withScenarioHarness` - Update `e2e/README.md` and `.agents/skills/e2e-tests/SKILL.md` to document the new helper and Deno-specific conventions - Clean up `js/smoke/README.md` and `js/smoke/shared/README.md` to remove references to the deleted smoke scenarios
1 parent d7181d0 commit ad230f4

32 files changed

Lines changed: 3095 additions & 331 deletions

.agents/skills/e2e-tests/SKILL.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pnpm run build # Build SDK (required if source changed)
1414
cd e2e && npx vitest run scenarios/<name>/scenario.test.ts # Run one scenario
1515
cd e2e && npx vitest run --reporter=verbose scenarios/<name>/scenario.test.ts # Verbose
1616
cd e2e && npx vitest run --update scenarios/<name>/scenario.test.ts # Update snapshots
17+
cd e2e && npx vitest run -t "<exact test name>" # Isolate one test when file args over-match
1718
pnpm run test:e2e # Run all (from repo root)
1819
pnpm run test:e2e:hermetic # Run hermetic-only e2e tests
1920
pnpm run test:e2e:external # Run external-api-only e2e tests
@@ -67,7 +68,7 @@ test(
6768
);
6869
```
6970

70-
Key harness methods: `runScenarioDir()`, `runNodeScenarioDir()`, `testRunEvents()`, `events()`, `payloads()`, `requestsAfter(cursor)`, `testRunId`.
71+
Key harness methods: `runScenarioDir()`, `runNodeScenarioDir()`, `runDenoScenarioDir()`, `testRunEvents()`, `events()`, `payloads()`, `requestsAfter(cursor)`, `testRunId`.
7172

7273
For wrapper scenarios use `events()` (not `testRunEvents()`) and scope payloads via `payloadRowsForRootSpan()`.
7374

@@ -123,10 +124,18 @@ import { runMyImpl } from "./scenario.impl";
123124

124125
Test loops over versions with `for (const s of scenarios) { test(...) }`. See `wrap-ai-sdk-generation-traces` or `ai-sdk-otel-export`.
125126

126-
### Runner-wrapper (vitest/node:test)
127+
### Runner-wrapper (vitest/node:test/deno)
127128

128129
When the wrapper runs inside a nested test runner, `scenario.ts` spawns a second process via `runNodeSubprocess`. The nested runner file must NOT be named `*.test.ts`. Tag all data with `metadata.testRunId` and use `payloadRowsForTestRunId()`. See `wrap-vitest-suite-traces`.
129130

131+
Use:
132+
133+
- `runNodeScenarioDir()` for plain Node nested runners
134+
- `runDenoScenarioDir()` for Deno nested runners
135+
- `runner.case.ts` for nested Deno entrypoints
136+
137+
Deno scenarios can have intentionally different runtime contracts from Node. Assert the actual Deno/browser behavior rather than copying Node parent-child expectations blindly. See `e2e/scenarios/deno-browser/`.
138+
130139
### OTEL export
131140

132141
Set up `BraintrustExporter`/`BraintrustSpanProcessor` pointed at the mock server, register globally, then assert on `/otel/v1/traces` requests via `requestsAfter()` + `extractOtelSpans()`. See `ai-sdk-otel-export` or `otel-span-processor-export`.
@@ -145,6 +154,8 @@ Set up `BraintrustExporter`/`BraintrustSpanProcessor` pointed at the mock server
145154

146155
Scenarios run from `e2e/.bt-tmp/run-<id>/scenarios/<name>/`. Node walks up to `e2e/node_modules/` for workspace deps (`braintrust`, `@braintrust/otel`, etc.). Scenario-local deps are in the scenario's own `node_modules/`. Helper imports (`../../helpers/...`) work because `prepareScenarioDir` copies `e2e/helpers/` into the temp dir.
147156

157+
Deno nested runners use `runDenoScenarioDir()`, which invokes `deno test --no-check` with the harness env vars and the prepared temp scenario path.
158+
148159
## Debugging
149160

150161
- **Subprocess error**: Read the `STDERR` section in the error message.

.github/workflows/checks.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ jobs:
198198
timeout-minutes: 30
199199
steps:
200200
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
201+
- uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
202+
with:
203+
deno-version-file: .tool-versions
201204
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
202205
with:
203206
node-version-file: .tool-versions

.tool-versions

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
nodejs 22.15.0
22
npm 11.6.2
33
pnpm 10.26.2
4-
npm:@sentry/dotagents 1.4.0
4+
deno 2.7.6
5+
npm:@sentry/dotagents 1.5.0

e2e/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ The main utilities you'll use in test files:
6060
- `resolveScenarioDir(import.meta.url)` - Resolves the folder that contains the current test.
6161
- `installScenarioDependencies({ scenarioDir })` - Installs optional scenario-local dependencies.
6262
- `runScenarioDir({ scenarioDir, entry?, timeoutMs? })` - Runs a TypeScript scenario with `tsx`.
63+
- `runDenoScenarioDir({ scenarioDir, entry?, args?, timeoutMs? })` - Runs nested Deno scenarios with `deno test`.
6364
- `runNodeScenarioDir({ scenarioDir, entry?, nodeArgs?, timeoutMs? })` - Runs plain Node scenarios, used for `--import braintrust/hook.mjs`.
6465
- `testRunEvents()` - Returns parsed events tagged with the current test run id.
6566
- `events()`, `payloads()`, `requestCursor()`, `requestsAfter()` - Lower-level access for ingestion payloads and HTTP request flow assertions.
@@ -108,6 +109,8 @@ Some wrappers execute inside a nested test runner rather than a single SDK call.
108109
- Tag every traced test/eval with `metadata.testRunId` so the outer assertions can isolate rows across multiple trace roots with `payloadRowsForTestRunId(...)`.
109110
- If a nested runner needs its own test discovery rules, keep that config local to the scenario folder so the shared e2e config stays unchanged.
110111

112+
The Deno scenarios follow the same pattern, except the harness invokes `deno test` via `runDenoScenarioDir(...)` and the nested runner entrypoint lives in `runner.case.ts`.
113+
111114
### Environment variables
112115

113116
`externalApi` scenarios require provider credentials in addition to the mock Braintrust server config supplied by the harness:

e2e/helpers/deno-test-helpers.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
type BraintrustModule = Record<string, unknown>;
2+
3+
function assert(
4+
condition: unknown,
5+
message: string,
6+
): asserts condition is true {
7+
if (!condition) {
8+
throw new Error(message);
9+
}
10+
}
11+
12+
function isRecord(value: unknown): value is Record<string, unknown> {
13+
return typeof value === "object" && value !== null && !Array.isArray(value);
14+
}
15+
16+
export function getTestRunId(): string {
17+
const testRunId = Deno.env.get("BRAINTRUST_E2E_RUN_ID");
18+
assert(testRunId, "BRAINTRUST_E2E_RUN_ID must be set");
19+
return testRunId;
20+
}
21+
22+
export function scopedName(base: string): string {
23+
return `${base}-${getTestRunId()
24+
.toLowerCase()
25+
.replace(/[^a-z0-9-]/g, "-")}`;
26+
}
27+
28+
export function expectNamedExports(
29+
module: BraintrustModule,
30+
exportNames: string[],
31+
): void {
32+
for (const exportName of exportNames) {
33+
assert(module[exportName], `Expected export "${exportName}" to exist`);
34+
}
35+
}
36+
37+
export function expectBuildType(
38+
module: BraintrustModule,
39+
expectedBuildType: string,
40+
): void {
41+
const testingOnly = module._exportsForTestingOnly;
42+
assert(isRecord(testingOnly), "_exportsForTestingOnly must exist");
43+
44+
const isomorph = testingOnly.isomorph;
45+
assert(isRecord(isomorph), "_exportsForTestingOnly.isomorph must exist");
46+
assert(
47+
isomorph.buildType === expectedBuildType,
48+
`Expected build type "${expectedBuildType}" but got "${String(isomorph.buildType)}"`,
49+
);
50+
}
51+
52+
export function expectMustacheTemplate(module: BraintrustModule): void {
53+
const Prompt = module.Prompt as
54+
| (new (...args: unknown[]) => {
55+
build: (
56+
args: Record<string, unknown>,
57+
options: { templateFormat: string },
58+
) => { messages?: Array<{ content?: string }> };
59+
})
60+
| undefined;
61+
62+
assert(Prompt, "Prompt export must exist");
63+
64+
const prompt = new Prompt(
65+
{
66+
name: "mustache-test",
67+
slug: "mustache-test",
68+
prompt_data: {
69+
prompt: {
70+
type: "chat",
71+
messages: [{ role: "user", content: "Hello, {{name}}!" }],
72+
},
73+
options: { model: "gpt-4" },
74+
},
75+
},
76+
{},
77+
false,
78+
);
79+
80+
const result = prompt.build(
81+
{ name: "World" },
82+
{ templateFormat: "mustache" },
83+
);
84+
assert(
85+
result.messages?.[0]?.content === "Hello, World!",
86+
"Mustache template rendering failed",
87+
);
88+
}
89+
90+
export function expectNunjucksTemplateUnavailable(
91+
module: BraintrustModule,
92+
): void {
93+
const Prompt = module.Prompt as
94+
| (new (...args: unknown[]) => {
95+
build: (
96+
args: Record<string, unknown>,
97+
options: { templateFormat: string },
98+
) => unknown;
99+
})
100+
| undefined;
101+
102+
assert(Prompt, "Prompt export must exist");
103+
104+
const prompt = new Prompt(
105+
{
106+
name: "nunjucks-test",
107+
slug: "nunjucks-test",
108+
prompt_data: {
109+
prompt: {
110+
type: "chat",
111+
messages: [
112+
{
113+
role: "user",
114+
content:
115+
"Items: {% for item in items %}{{ item.name }}{% if not loop.last %}, {% endif %}{% endfor %}",
116+
},
117+
],
118+
},
119+
options: { model: "gpt-4" },
120+
},
121+
},
122+
{},
123+
false,
124+
);
125+
126+
let errorMessage: string | undefined;
127+
try {
128+
prompt.build(
129+
{
130+
items: [{ name: "apple" }, { name: "banana" }, { name: "cherry" }],
131+
},
132+
{ templateFormat: "nunjucks" },
133+
);
134+
} catch (error) {
135+
errorMessage = error instanceof Error ? error.message : String(error);
136+
}
137+
138+
assert(
139+
errorMessage?.includes("requires @braintrust/template-nunjucks"),
140+
`Expected missing nunjucks package error, got: ${errorMessage ?? "no error"}`,
141+
);
142+
}
143+
144+
export async function expectEvalWorks(module: BraintrustModule): Promise<void> {
145+
const Eval = module.Eval as
146+
| ((
147+
name: string,
148+
definition: Record<string, unknown>,
149+
options: Record<string, unknown>,
150+
) => Promise<Record<string, unknown>>)
151+
| undefined;
152+
153+
assert(Eval, "Eval export must exist");
154+
155+
const evalData = [
156+
{ input: "Alice", expected: "Hi Alice" },
157+
{ input: "Bob", expected: "Hi Bob" },
158+
{ input: "Charlie", expected: "Hi Charlie" },
159+
];
160+
161+
const result = await Eval(
162+
"deno-local-eval",
163+
{
164+
data: evalData,
165+
task: async (input: string) => `Hi ${input}`,
166+
scores: [
167+
({ expected, output }: { expected: string; output: string }) => ({
168+
name: "exact_match",
169+
score: output === expected ? 1 : 0,
170+
}),
171+
],
172+
},
173+
{
174+
noSendLogs: true,
175+
returnResults: true,
176+
},
177+
);
178+
179+
const summary = result.summary;
180+
const results = result.results;
181+
assert(Array.isArray(results), "Eval results must be an array");
182+
assert(
183+
results.length === evalData.length,
184+
"Eval returned the wrong row count",
185+
);
186+
assert(isRecord(summary), "Eval summary must exist");
187+
assert(isRecord(summary.scores), "Eval summary scores must exist");
188+
189+
const exactMatch = summary.scores.exact_match;
190+
assert(isRecord(exactMatch), "Eval exact_match summary must exist");
191+
assert(exactMatch.score === 1, "Eval exact_match summary must be 1");
192+
}

e2e/helpers/scenario-harness.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ interface ScenarioResult {
2929
}
3030

3131
const tsxCliPath = createRequire(import.meta.url).resolve("tsx/cli");
32+
const DENO_COMMAND = process.platform === "win32" ? "deno.exe" : "deno";
3233
const DEFAULT_SCENARIO_TIMEOUT_MS = 15_000;
3334
const HELPERS_DIR = path.dirname(fileURLToPath(import.meta.url));
3435
const REPO_ROOT = path.resolve(HELPERS_DIR, "../..");
@@ -77,13 +78,14 @@ function getTestServerEnv(
7778
}
7879

7980
async function runProcess(
81+
command: string,
8082
args: string[],
8183
cwd: string,
8284
env: Record<string, string>,
8385
timeoutMs: number,
8486
): Promise<ScenarioResult> {
8587
return await new Promise<ScenarioResult>((resolve, reject) => {
86-
const child = spawn(process.execPath, args, {
88+
const child = spawn(command, args, {
8789
cwd,
8890
env: {
8991
...process.env,
@@ -94,7 +96,9 @@ async function runProcess(
9496
const timeout = setTimeout(() => {
9597
child.kill("SIGTERM");
9698
reject(
97-
new Error(`Process ${args.join(" ")} timed out after ${timeoutMs}ms`),
99+
new Error(
100+
`Process ${command} ${args.join(" ")} timed out after ${timeoutMs}ms`,
101+
),
98102
);
99103
}, timeoutMs);
100104

@@ -146,6 +150,7 @@ async function runScenarioDirOrThrow(
146150
? [...(options.nodeArgs ?? []), scenarioPath]
147151
: [tsxCliPath, scenarioPath];
148152
const result = await runProcess(
153+
process.execPath,
149154
args,
150155
scenarioDir,
151156
env,
@@ -192,6 +197,39 @@ export async function runNodeScenarioDir(options: {
192197
});
193198
}
194199

200+
export async function runDenoScenarioDir(options: {
201+
args?: string[];
202+
entry?: string;
203+
env?: Record<string, string>;
204+
scenarioDir: string;
205+
timeoutMs?: number;
206+
}): Promise<ScenarioResult> {
207+
const entry = options.entry ?? "runner.case.ts";
208+
const result = await runProcess(
209+
DENO_COMMAND,
210+
[
211+
"test",
212+
"--no-check",
213+
"--allow-env",
214+
"--allow-net",
215+
"--allow-read",
216+
...(options.args ?? []),
217+
resolveEntryPath(options.scenarioDir, entry),
218+
],
219+
options.scenarioDir,
220+
options.env ?? {},
221+
options.timeoutMs ?? DEFAULT_SCENARIO_TIMEOUT_MS,
222+
);
223+
224+
if (result.exitCode !== 0) {
225+
throw new Error(
226+
`Scenario ${path.join(options.scenarioDir, entry)} failed with exit code ${result.exitCode}\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`,
227+
);
228+
}
229+
230+
return result;
231+
}
232+
195233
interface ScenarioHarness {
196234
events: (predicate?: EventPredicate) => CapturedLogEvent[];
197235
payloads: (predicate?: PayloadPredicate) => CapturedLogPayload[];
@@ -200,6 +238,13 @@ interface ScenarioHarness {
200238
after: number,
201239
predicate?: RequestPredicate,
202240
) => CapturedRequest[];
241+
runDenoScenarioDir: (options: {
242+
args?: string[];
243+
entry?: string;
244+
env?: Record<string, string>;
245+
scenarioDir: string;
246+
timeoutMs?: number;
247+
}) => Promise<ScenarioResult>;
203248
runNodeScenarioDir: (options: {
204249
entry?: string;
205250
env?: Record<string, string>;
@@ -231,6 +276,14 @@ export async function withScenarioHarness(
231276
requestCursor: () => server.requests.length,
232277
requestsAfter: (after, predicate) =>
233278
filterItems(server.requests.slice(after), predicate),
279+
runDenoScenarioDir: (options) =>
280+
runDenoScenarioDir({
281+
...options,
282+
env: {
283+
...testEnv,
284+
...(options.env ?? {}),
285+
},
286+
}),
234287
runNodeScenarioDir: (options) =>
235288
runNodeScenarioDir({
236289
...options,

0 commit comments

Comments
 (0)