Skip to content

Commit 7d972c8

Browse files
authored
add e2e tests (#134)
1 parent 2536d2a commit 7d972c8

File tree

9 files changed

+364
-24
lines changed

9 files changed

+364
-24
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212

1313
# testing
1414
/coverage
15+
/test-results/
16+
/playwright-report/
17+
/blob-report/
18+
/playwright/.cache/
1519

1620
# next.js
1721
/.next/

components/workflow/config/action-grid.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export function ActionGrid({
112112
<Search className="absolute top-2.5 left-2.5 size-4 text-muted-foreground" />
113113
<Input
114114
className="pl-8"
115+
data-testid="action-search-input"
115116
disabled={disabled}
116117
id="action-filter"
117118
onChange={(e) => setFilter(e.target.value)}
@@ -122,13 +123,14 @@ export function ActionGrid({
122123
</div>
123124
</div>
124125

125-
<div className="grid grid-cols-2 gap-2">
126+
<div className="grid grid-cols-2 gap-2" data-testid="action-grid">
126127
{filteredActions.map((action) => (
127128
<button
128129
className={cn(
129130
"flex flex-col items-center justify-center gap-3 rounded-lg border bg-card p-4 transition-colors hover:border-primary hover:bg-accent",
130131
disabled && "pointer-events-none opacity-50"
131132
)}
133+
data-testid={`action-option-${action.id.toLowerCase().replace(/\s+/g, "-")}`}
132134
disabled={disabled}
133135
key={action.id}
134136
onClick={() => onSelectAction(action.id)}

components/workflow/node-config-panel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,7 @@ export const PanelInner = () => {
740740
<>
741741
<Tabs
742742
className="size-full"
743+
data-testid="properties-panel"
743744
defaultValue="properties"
744745
onValueChange={setActiveTab}
745746
value={activeTab}

components/workflow/nodes/action-node.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ export const ActionNode = memo(({ data, selected, id }: ActionNodeProps) => {
273273
selected && "border-primary",
274274
isDisabled && "opacity-50"
275275
)}
276+
data-testid={`action-node-${id}`}
276277
handles={{ target: true, source: true }}
277278
status={status}
278279
>
@@ -342,6 +343,7 @@ export const ActionNode = memo(({ data, selected, id }: ActionNodeProps) => {
342343
selected && "border-primary",
343344
isDisabled && "opacity-50"
344345
)}
346+
data-testid={`action-node-${id}`}
345347
handles={{ target: true, source: true }}
346348
status={status}
347349
>

components/workflow/workflow-canvas.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,7 @@ export function WorkflowCanvas() {
449449
return (
450450
<div
451451
className="relative h-full bg-background"
452+
data-testid="workflow-canvas"
452453
style={{
453454
opacity: isCanvasReady ? 1 : 0,
454455
width: rightPanelWidth ? `calc(100% - ${rightPanelWidth})` : "100%",

e2e/workflow.spec.ts

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
const SELECTED_CLASS_REGEX = /selected/;
4+
5+
test.describe("Workflow Editor", () => {
6+
test.beforeEach(async ({ page }) => {
7+
// Navigate to the homepage which has an embedded workflow canvas
8+
await page.goto("/", { waitUntil: "domcontentloaded" });
9+
// Wait for the canvas to be ready
10+
await page.waitForSelector('[data-testid="workflow-canvas"]', {
11+
state: "visible",
12+
timeout: 60_000,
13+
});
14+
});
15+
16+
test("workflow canvas loads", async ({ page }) => {
17+
// Verify the canvas container is visible
18+
const canvas = page.locator('[data-testid="workflow-canvas"]');
19+
await expect(canvas).toBeVisible();
20+
21+
// Verify React Flow is rendered
22+
const reactFlow = page.locator(".react-flow");
23+
await expect(reactFlow).toBeVisible();
24+
});
25+
26+
test("can create a new step by dragging from a node", async ({ page }) => {
27+
// Wait for any existing nodes to be visible
28+
await page.waitForTimeout(1000);
29+
30+
// Find the trigger node's source handle
31+
const triggerHandle = page.locator(
32+
".react-flow__node-trigger .react-flow__handle-source"
33+
);
34+
35+
// If there's a trigger node, drag from it to create a new node
36+
if (await triggerHandle.isVisible()) {
37+
const handleBox = await triggerHandle.boundingBox();
38+
if (handleBox) {
39+
// Start drag from handle
40+
await page.mouse.move(
41+
handleBox.x + handleBox.width / 2,
42+
handleBox.y + handleBox.height / 2
43+
);
44+
await page.mouse.down();
45+
46+
// Drag to empty area
47+
await page.mouse.move(handleBox.x + 300, handleBox.y);
48+
await page.mouse.up();
49+
50+
// Wait for the new node to appear
51+
await page.waitForTimeout(500);
52+
53+
// Verify a new action node was created (checking for action grid in properties)
54+
const actionGrid = page.locator('[data-testid="action-grid"]');
55+
await expect(actionGrid).toBeVisible({ timeout: 5000 });
56+
}
57+
}
58+
});
59+
60+
test("search input is auto-focused when creating a new step", async ({
61+
page,
62+
}) => {
63+
// Wait for any existing nodes
64+
await page.waitForTimeout(1000);
65+
66+
// Find the trigger node's source handle
67+
const triggerHandle = page.locator(
68+
".react-flow__node-trigger .react-flow__handle-source"
69+
);
70+
71+
if (await triggerHandle.isVisible()) {
72+
const handleBox = await triggerHandle.boundingBox();
73+
if (handleBox) {
74+
// Drag from handle to create new node
75+
await page.mouse.move(
76+
handleBox.x + handleBox.width / 2,
77+
handleBox.y + handleBox.height / 2
78+
);
79+
await page.mouse.down();
80+
await page.mouse.move(handleBox.x + 300, handleBox.y);
81+
await page.mouse.up();
82+
83+
// Wait for new node and action grid
84+
await page.waitForTimeout(500);
85+
86+
// Verify the search input is focused
87+
const searchInput = page.locator('[data-testid="action-search-input"]');
88+
await expect(searchInput).toBeFocused({ timeout: 5000 });
89+
}
90+
}
91+
});
92+
93+
test("search input is NOT auto-focused when selecting existing unconfigured step", async ({
94+
page,
95+
}) => {
96+
// First create a new step
97+
const triggerHandle = page.locator(
98+
".react-flow__node-trigger .react-flow__handle-source"
99+
);
100+
101+
if (await triggerHandle.isVisible()) {
102+
const handleBox = await triggerHandle.boundingBox();
103+
if (handleBox) {
104+
// Create new node
105+
await page.mouse.move(
106+
handleBox.x + handleBox.width / 2,
107+
handleBox.y + handleBox.height / 2
108+
);
109+
await page.mouse.down();
110+
await page.mouse.move(handleBox.x + 300, handleBox.y);
111+
await page.mouse.up();
112+
113+
await page.waitForTimeout(500);
114+
115+
// Click on canvas to deselect
116+
const canvas = page.locator('[data-testid="workflow-canvas"]');
117+
const canvasBox = await canvas.boundingBox();
118+
if (canvasBox) {
119+
// Click on empty area of canvas
120+
await page.mouse.click(canvasBox.x + 50, canvasBox.y + 50);
121+
await page.waitForTimeout(300);
122+
}
123+
124+
// Find the action node and click on it
125+
const actionNode = page.locator(".react-flow__node-action").first();
126+
if (await actionNode.isVisible()) {
127+
await actionNode.click();
128+
await page.waitForTimeout(300);
129+
130+
// Verify search input is visible but NOT focused
131+
const searchInput = page.locator(
132+
'[data-testid="action-search-input"]'
133+
);
134+
await expect(searchInput).toBeVisible({ timeout: 5000 });
135+
136+
// The search input should NOT be focused when re-selecting an existing node
137+
await expect(searchInput).not.toBeFocused();
138+
}
139+
}
140+
}
141+
});
142+
143+
test("can select and deselect nodes", async ({ page }) => {
144+
// Wait for nodes to be visible
145+
await page.waitForTimeout(1000);
146+
147+
// Find trigger node
148+
const triggerNode = page.locator(".react-flow__node-trigger").first();
149+
150+
if (await triggerNode.isVisible()) {
151+
// Click to select
152+
await triggerNode.click();
153+
await page.waitForTimeout(300);
154+
155+
// Verify node is selected (has border-primary class or selected attribute)
156+
await expect(triggerNode).toHaveClass(SELECTED_CLASS_REGEX);
157+
158+
// Click on canvas to deselect
159+
const canvas = page.locator('[data-testid="workflow-canvas"]');
160+
const canvasBox = await canvas.boundingBox();
161+
if (canvasBox) {
162+
await page.mouse.click(canvasBox.x + 50, canvasBox.y + 50);
163+
await page.waitForTimeout(300);
164+
}
165+
166+
// Verify node is deselected
167+
await expect(triggerNode).not.toHaveClass(SELECTED_CLASS_REGEX);
168+
}
169+
});
170+
171+
test("can select an action type for a new step", async ({ page }) => {
172+
// First create a new step
173+
const triggerHandle = page.locator(
174+
".react-flow__node-trigger .react-flow__handle-source"
175+
);
176+
177+
if (await triggerHandle.isVisible()) {
178+
const handleBox = await triggerHandle.boundingBox();
179+
if (handleBox) {
180+
// Create new node
181+
await page.mouse.move(
182+
handleBox.x + handleBox.width / 2,
183+
handleBox.y + handleBox.height / 2
184+
);
185+
await page.mouse.down();
186+
await page.mouse.move(handleBox.x + 300, handleBox.y);
187+
await page.mouse.up();
188+
189+
await page.waitForTimeout(500);
190+
191+
// Wait for action grid to appear
192+
const actionGrid = page.locator('[data-testid="action-grid"]');
193+
await expect(actionGrid).toBeVisible({ timeout: 5000 });
194+
195+
// Click on HTTP Request action
196+
const httpRequestAction = page.locator(
197+
'[data-testid="action-option-http-request"]'
198+
);
199+
await expect(httpRequestAction).toBeVisible();
200+
await httpRequestAction.click();
201+
202+
// Wait for the action to be selected
203+
await page.waitForTimeout(500);
204+
205+
// Verify the action grid is no longer visible (node is now configured)
206+
await expect(actionGrid).not.toBeVisible({ timeout: 5000 });
207+
208+
// Verify the node now shows the HTTP Request configuration
209+
// The action node should no longer show the action selection grid
210+
const selectedActionNode = page.locator(".react-flow__node-action");
211+
await expect(selectedActionNode).toBeVisible();
212+
}
213+
}
214+
});
215+
216+
test("search filters actions in the action grid", async ({ page }) => {
217+
// First create a new step
218+
const triggerHandle = page.locator(
219+
".react-flow__node-trigger .react-flow__handle-source"
220+
);
221+
222+
if (await triggerHandle.isVisible()) {
223+
const handleBox = await triggerHandle.boundingBox();
224+
if (handleBox) {
225+
// Create new node
226+
await page.mouse.move(
227+
handleBox.x + handleBox.width / 2,
228+
handleBox.y + handleBox.height / 2
229+
);
230+
await page.mouse.down();
231+
await page.mouse.move(handleBox.x + 300, handleBox.y);
232+
await page.mouse.up();
233+
234+
await page.waitForTimeout(500);
235+
236+
// Wait for search input
237+
const searchInput = page.locator('[data-testid="action-search-input"]');
238+
await expect(searchInput).toBeVisible({ timeout: 5000 });
239+
240+
// Type in search
241+
await searchInput.fill("HTTP");
242+
await page.waitForTimeout(300);
243+
244+
// Verify HTTP Request is visible
245+
const httpRequestAction = page.locator(
246+
'[data-testid="action-option-http-request"]'
247+
);
248+
await expect(httpRequestAction).toBeVisible();
249+
250+
// Verify non-matching actions are filtered out
251+
const conditionAction = page.locator(
252+
'[data-testid="action-option-condition"]'
253+
);
254+
await expect(conditionAction).not.toBeVisible();
255+
}
256+
}
257+
});
258+
});

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
"db:push": "drizzle-kit push",
1717
"db:studio": "drizzle-kit studio",
1818
"discover-plugins": "tsx scripts/discover-plugins.ts",
19-
"create-plugin": "tsx scripts/create-plugin.ts"
19+
"create-plugin": "tsx scripts/create-plugin.ts",
20+
"test:e2e": "playwright test",
21+
"test:e2e:ui": "playwright test --ui"
2022
},
2123
"dependencies": {
2224
"@ai-sdk/provider": "^2.0.0",
@@ -62,6 +64,7 @@
6264
},
6365
"devDependencies": {
6466
"@inquirer/prompts": "^8.0.1",
67+
"@playwright/test": "^1.57.0",
6568
"@tailwindcss/postcss": "^4",
6669
"@types/node": "^24",
6770
"@types/react": "^19",

playwright.config.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
export default defineConfig({
4+
testDir: "./e2e",
5+
fullyParallel: true,
6+
forbidOnly: !!process.env.CI,
7+
retries: process.env.CI ? 2 : 0,
8+
workers: process.env.CI ? 1 : undefined,
9+
reporter: "html",
10+
timeout: 60_000,
11+
use: {
12+
baseURL: "http://localhost:3000",
13+
trace: "on-first-retry",
14+
screenshot: "only-on-failure",
15+
navigationTimeout: 60_000,
16+
},
17+
projects: [
18+
{
19+
name: "chromium",
20+
use: { ...devices["Desktop Chrome"] },
21+
},
22+
],
23+
webServer: {
24+
command: "pnpm dev",
25+
url: "http://localhost:3000",
26+
reuseExistingServer: !process.env.CI,
27+
timeout: 120_000,
28+
},
29+
});

0 commit comments

Comments
 (0)