Skip to content

Commit 4a01e71

Browse files
SippinOnJuiceBoxadriandlam
authored andcommitted
feat: add e2e tests and fix bundling and injection issue
1 parent 062faa5 commit 4a01e71

File tree

10 files changed

+436
-68
lines changed

10 files changed

+436
-68
lines changed

packages/bun/e2e/e2e.test.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { dehydrateWorkflowArguments } from '@workflow/core/serialization';
2+
import { describe, expect, test } from 'vitest';
3+
4+
const deploymentUrl = process.env.DEPLOYMENT_URL || 'http://localhost:3000';
5+
6+
async function triggerWorkflow(
7+
workflow: string | { workflowFile: string; workflowFn: string },
8+
args: any[]
9+
): Promise<{ runId: string }> {
10+
const url = new URL('/api/trigger', deploymentUrl);
11+
const workflowFn =
12+
typeof workflow === 'string' ? workflow : workflow.workflowFn;
13+
const workflowFile =
14+
typeof workflow === 'string'
15+
? 'workflows/99_e2e.ts'
16+
: workflow.workflowFile;
17+
18+
url.searchParams.set('workflowFile', workflowFile);
19+
url.searchParams.set('workflowFn', workflowFn);
20+
const res = await fetch(url, {
21+
method: 'POST',
22+
body: JSON.stringify(dehydrateWorkflowArguments(args, [], globalThis)),
23+
});
24+
if (!res.ok) {
25+
throw new Error(
26+
`Failed to trigger workflow: ${res.url} ${res.status}: ${await res.text()}`
27+
);
28+
}
29+
const run = await res.json();
30+
return run;
31+
}
32+
33+
async function getWorkflowReturnValue(runId: string) {
34+
// Poll the GET endpoint until the workflow run is completed
35+
while (true) {
36+
const url = new URL('/api/trigger', deploymentUrl);
37+
url.searchParams.set('runId', runId);
38+
39+
const res = await fetch(url);
40+
41+
if (res.status === 202) {
42+
// Workflow run is still running, wait and poll again
43+
await new Promise((resolve) => setTimeout(resolve, 5_000));
44+
continue;
45+
}
46+
const contentType = res.headers.get('Content-Type');
47+
48+
if (contentType?.includes('application/json')) {
49+
return await res.json();
50+
}
51+
52+
if (contentType?.includes('application/octet-stream')) {
53+
return res.body;
54+
}
55+
56+
throw new Error(`Unexpected content type: ${contentType}`);
57+
}
58+
}
59+
60+
describe('bun workbench e2e', () => {
61+
test('calc workflow (0_demo.ts)', { timeout: 60_000 }, async () => {
62+
const run = await triggerWorkflow(
63+
{ workflowFile: 'workflows/0_calc.ts', workflowFn: 'calc' },
64+
[2]
65+
);
66+
const returnValue = await getWorkflowReturnValue(run.runId);
67+
expect(returnValue).toBe(4);
68+
});
69+
70+
test('addTenWorkflow', { timeout: 60_000 }, async () => {
71+
const run = await triggerWorkflow(
72+
{ workflowFile: 'workflows/99_e2e.ts', workflowFn: 'addTenWorkflow' },
73+
[123]
74+
);
75+
const returnValue = await getWorkflowReturnValue(run.runId);
76+
expect(returnValue).toBe(133);
77+
});
78+
79+
test('promiseAllWorkflow', { timeout: 60_000 }, async () => {
80+
const run = await triggerWorkflow('promiseAllWorkflow', []);
81+
const returnValue = await getWorkflowReturnValue(run.runId);
82+
expect(returnValue).toBe('ABC');
83+
});
84+
85+
test('promiseRaceWorkflow', { timeout: 60_000 }, async () => {
86+
const run = await triggerWorkflow('promiseRaceWorkflow', []);
87+
const returnValue = await getWorkflowReturnValue(run.runId);
88+
expect(returnValue).toBe('B');
89+
});
90+
91+
test('hookWorkflow', { timeout: 60_000 }, async () => {
92+
const token = Math.random().toString(36).slice(2);
93+
const customData = Math.random().toString(36).slice(2);
94+
95+
const run = await triggerWorkflow('hookWorkflow', [token, customData]);
96+
97+
// Wait for webhook to be registered
98+
await new Promise((resolve) => setTimeout(resolve, 5_000));
99+
100+
const hookUrl = new URL('/api/hook', deploymentUrl);
101+
102+
let res = await fetch(hookUrl, {
103+
method: 'POST',
104+
body: JSON.stringify({ token, data: { message: 'one' } }),
105+
});
106+
expect(res.status).toBe(200);
107+
let body = await res.json();
108+
expect(body.runId).toBe(run.runId);
109+
110+
// Invalid token test
111+
res = await fetch(hookUrl, {
112+
method: 'POST',
113+
body: JSON.stringify({ token: 'invalid' }),
114+
});
115+
expect(res.status).toBe(404);
116+
body = await res.json();
117+
expect(body).toBeNull();
118+
119+
res = await fetch(hookUrl, {
120+
method: 'POST',
121+
body: JSON.stringify({ token, data: { message: 'two' } }),
122+
});
123+
expect(res.status).toBe(200);
124+
125+
res = await fetch(hookUrl, {
126+
method: 'POST',
127+
body: JSON.stringify({ token, data: { message: 'three', done: true } }),
128+
});
129+
expect(res.status).toBe(200);
130+
131+
const returnValue = await getWorkflowReturnValue(run.runId);
132+
expect(returnValue).toBeInstanceOf(Array);
133+
expect(returnValue.length).toBe(3);
134+
expect(returnValue[0].message).toBe('one');
135+
expect(returnValue[0].customData).toBe(customData);
136+
expect(returnValue[1].message).toBe('two');
137+
expect(returnValue[2].message).toBe('three');
138+
expect(returnValue[2].done).toBe(true);
139+
});
140+
141+
test('webhookWorkflow', { timeout: 60_000 }, async () => {
142+
const token = Math.random().toString(36).slice(2);
143+
const token2 = Math.random().toString(36).slice(2);
144+
const token3 = Math.random().toString(36).slice(2);
145+
146+
const run = await triggerWorkflow('webhookWorkflow', [
147+
token,
148+
token2,
149+
token3,
150+
]);
151+
152+
// Wait for webhooks to be registered
153+
await new Promise((resolve) => setTimeout(resolve, 5_000));
154+
155+
// Webhook with default response
156+
const res = await fetch(
157+
new URL(
158+
`/.well-known/workflow/v1/webhook/${encodeURIComponent(token)}`,
159+
deploymentUrl
160+
),
161+
{
162+
method: 'POST',
163+
body: JSON.stringify({ message: 'one' }),
164+
}
165+
);
166+
expect(res.status).toBe(202);
167+
168+
// Webhook with static response
169+
const res2 = await fetch(
170+
new URL(
171+
`/.well-known/workflow/v1/webhook/${encodeURIComponent(token2)}`,
172+
deploymentUrl
173+
),
174+
{
175+
method: 'POST',
176+
body: JSON.stringify({ message: 'two' }),
177+
}
178+
);
179+
expect(res2.status).toBe(402);
180+
181+
// Webhook with manual response
182+
const res3 = await fetch(
183+
new URL(
184+
`/.well-known/workflow/v1/webhook/${encodeURIComponent(token3)}`,
185+
deploymentUrl
186+
),
187+
{
188+
method: 'POST',
189+
body: JSON.stringify({ message: 'three' }),
190+
}
191+
);
192+
expect(res3.status).toBe(200);
193+
194+
const returnValue = await getWorkflowReturnValue(run.runId);
195+
expect(returnValue).toHaveLength(3);
196+
expect(returnValue[0].method).toBe('POST');
197+
expect(returnValue[1].method).toBe('POST');
198+
expect(returnValue[2].method).toBe('POST');
199+
});
200+
201+
test('webhook route with invalid token', { timeout: 60_000 }, async () => {
202+
const invalidWebhookUrl = new URL(
203+
`/.well-known/workflow/v1/webhook/${encodeURIComponent('invalid')}`,
204+
deploymentUrl
205+
);
206+
const res = await fetch(invalidWebhookUrl, {
207+
method: 'POST',
208+
body: JSON.stringify({}),
209+
});
210+
expect(res.status).toBe(404);
211+
});
212+
213+
test('sleepingWorkflow', { timeout: 60_000 }, async () => {
214+
const run = await triggerWorkflow('sleepingWorkflow', []);
215+
const returnValue = await getWorkflowReturnValue(run.runId);
216+
expect(returnValue.startTime).toBeLessThan(returnValue.endTime);
217+
expect(returnValue.endTime - returnValue.startTime).toBeGreaterThan(9999);
218+
});
219+
});

packages/bun/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"scripts": {
2323
"build": "tsc",
2424
"dev": "tsc --watch",
25-
"clean": "tsc --build --clean && rm -rf dist"
25+
"clean": "tsc --build --clean && rm -rf dist",
26+
"test:e2e": "vitest run e2e"
2627
},
2728
"dependencies": {
2829
"@swc/core": "1.11.24",
@@ -35,6 +36,7 @@
3536
"devDependencies": {
3637
"@types/bun": "^1.3.1",
3738
"@types/node": "catalog:",
38-
"@workflow/tsconfig": "workspace:*"
39+
"@workflow/tsconfig": "workspace:*",
40+
"vitest": "catalog:"
3941
}
4042
}

packages/bun/src/index.ts

Lines changed: 11 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { writeFileSync } from 'node:fs';
22
import { mkdir } from 'node:fs/promises';
3-
import { dirname, join } from 'node:path';
3+
import { join } from 'node:path';
44
import { transform } from '@swc/core';
55
import { BaseBuilder } from '@workflow/cli/dist/lib/builders/base-builder';
66
import type { WorkflowConfig } from '@workflow/cli/dist/lib/config/types';
@@ -10,12 +10,13 @@ export function workflowPlugin(): BunPlugin {
1010
return {
1111
name: 'workflow-plugin',
1212
async setup(build) {
13-
// Build workflows on startup
1413
await new LocalBuilder().build();
1514

16-
// Client transform plugin
17-
build.onLoad({ filter: /\.(ts|tsx|js|jsx)$/ }, async (args) => {
15+
// Client transform plugin - only transform TypeScript files
16+
// JS files are already built
17+
build.onLoad({ filter: /\.(ts|tsx)$/ }, async (args) => {
1818
const source = await Bun.file(args.path).text();
19+
1920
// Optimization: Skip files that do not have any directives
2021
if (!source.match(/(use step|use workflow)/)) {
2122
return { contents: source };
@@ -69,62 +70,14 @@ export class LocalBuilder extends BaseBuilder {
6970
inputFiles,
7071
});
7172

72-
await this.createBunWebhookBundle(join(this.#outDir, 'webhook.js'));
73-
74-
// Add .workflows to .gitignore
75-
writeFileSync(join(this.#outDir, '.gitignore'), '*\n');
76-
}
77-
78-
private async createBunWebhookBundle(outfile: string): Promise<void> {
79-
console.log('Creating webhook route');
80-
await mkdir(dirname(outfile), { recursive: true });
81-
82-
const routeContent = `import { resumeWebhook } from 'workflow/api';
83-
84-
async function handler(request) {
85-
const url = new URL(request.url);
86-
const pathParts = url.pathname.split('/');
87-
const token = decodeURIComponent(pathParts[pathParts.length - 1]);
88-
89-
if (!token) {
90-
return new Response('Missing token', { status: 400 });
91-
}
92-
93-
try {
94-
const response = await resumeWebhook(token, request);
95-
return response;
96-
} catch (error) {
97-
console.error('Error during resumeWebhook', error);
98-
return new Response(null, { status: 404 });
99-
}
100-
}
101-
102-
export const GET = handler;
103-
export const POST = handler;
104-
export const PUT = handler;
105-
export const PATCH = handler;
106-
export const DELETE = handler;
107-
export const HEAD = handler;
108-
export const OPTIONS = handler;
109-
`;
110-
111-
const tempFile = join(dirname(outfile), 'webhook-temp.js');
112-
writeFileSync(tempFile, routeContent);
113-
114-
const result = await Bun.build({
115-
entrypoints: [tempFile],
116-
outdir: dirname(outfile),
117-
naming: 'webhook.js',
118-
target: 'bun',
119-
format: 'esm',
73+
await this.createWebhookBundle({
74+
outfile: join(this.#outDir, 'webhook.js'),
75+
bundle: false,
12076
});
12177

122-
if (!result.success) {
123-
throw new Error('Failed to build webhook bundle');
124-
}
78+
console.log('Created webhook bundle');
12579

126-
// Clean up temp file
127-
const fs = await import('node:fs');
128-
fs.unlinkSync(tempFile);
80+
// Add .workflows to .gitignore
81+
writeFileSync(join(this.#outDir, '.gitignore'), '*\n');
12982
}
13083
}

packages/bun/vitest.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineConfig } from 'vitest/config';
2+
3+
export default defineConfig({
4+
test: {
5+
globals: true,
6+
environment: 'node',
7+
include: ['e2e/**/*.test.ts'],
8+
},
9+
});

pnpm-lock.yaml

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

workbench/bun/_workflows.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as demo from './workflows/0_demo.ts';
2+
import * as simple from './workflows/1_simple.ts';
3+
import * as controlFlow from './workflows/2_control_flow.ts';
4+
import * as streams from './workflows/3_streams.ts';
5+
import * as ai from './workflows/4_ai.ts';
6+
import * as hooks from './workflows/5_hooks.ts';
7+
import * as batching from './workflows/6_batching.ts';
8+
import * as duplicate from './workflows/98_duplicate_case.ts';
9+
import * as e2e from './workflows/99_e2e.ts';
10+
11+
export const allWorkflows = {
12+
'workflows/0_calc.ts': demo,
13+
'workflows/1_simple.ts': simple,
14+
'workflows/2_control_flow.ts': controlFlow,
15+
'workflows/3_streams.ts': streams,
16+
'workflows/4_ai.ts': ai,
17+
'workflows/5_hooks.ts': hooks,
18+
'workflows/6_batching.ts': batching,
19+
'workflows/98_duplicate_case.ts': duplicate,
20+
'workflows/99_e2e.ts': e2e,
21+
};

workbench/bun/bunfig.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
preload = ["./workflow-plugin.ts"]
2+

workbench/bun/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"type": "module",
55
"private": true,
66
"scripts": {
7-
"dev": "bun --hot server.ts"
7+
"dev": "bun server.ts"
88
},
99
"dependencies": {
1010
"workflow": "workspace:*"

0 commit comments

Comments
 (0)