@sweny-ai/core 0.1.31 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * sweny e2e — Generate and run agent-driven end-to-end browser tests.
3
+ *
4
+ * Pure functions for workflow generation + thin @clack/prompts interactive layer.
5
+ * Tests cover the pure functions; the interactive wizard is thin glue.
6
+ */
7
+ import type { Workflow, Node } from "../types.js";
8
+ export type FlowType = "registration" | "login" | "purchase" | "onboarding" | "upgrade" | "cancellation" | "custom";
9
+ export interface FlowConfig {
10
+ type: FlowType;
11
+ /** URL path for the flow (e.g. /signup, /login, /pricing) */
12
+ path: string;
13
+ /** Required form fields (registration only) */
14
+ fields?: string[];
15
+ /** Whether email verification is required (registration only) */
16
+ emailVerification?: boolean;
17
+ /** Success redirect path (registration only) */
18
+ successRedirect?: string;
19
+ /** Payment provider name (purchase only) */
20
+ paymentProvider?: string;
21
+ /** Free-text description (custom flow only) */
22
+ description?: string;
23
+ /** What constitutes success (most flow types) */
24
+ successCriteria?: string;
25
+ }
26
+ export interface CleanupConfig {
27
+ enabled: boolean;
28
+ backend?: string;
29
+ }
30
+ export interface E2eSelections {
31
+ flows: FlowConfig[];
32
+ baseUrl: string;
33
+ cleanup: CleanupConfig;
34
+ }
35
+ /**
36
+ * Replace {var_name} placeholders in text with values from the vars map.
37
+ * Unknown variables are left untouched.
38
+ */
39
+ export declare function resolveTemplateVars(text: string, vars: Record<string, string>): string;
40
+ /**
41
+ * Build the template variable map from environment variables.
42
+ * Auto-generates run_id, test_email, test_password.
43
+ * Reads E2E_BASE_URL, E2E_EMAIL, E2E_PASSWORD, and any E2E_* custom vars.
44
+ */
45
+ export declare function buildE2eVars(env: Record<string, string | undefined>): Record<string, string>;
46
+ export declare function buildSetupNode(): Node;
47
+ export declare function buildReportNode(testNodeIds: string[]): Node;
48
+ export declare function buildCleanupNode(backend?: string): Node;
49
+ interface FlowNodes {
50
+ nodes: Record<string, Node>;
51
+ testNodeIds: string[];
52
+ }
53
+ /**
54
+ * Build the test nodes for a given flow type.
55
+ * Auth-dependent flows (purchase, onboarding, upgrade, cancellation)
56
+ * automatically include a login node.
57
+ */
58
+ export declare function buildFlowNodes(config: FlowConfig): FlowNodes;
59
+ /**
60
+ * Build a complete Workflow object for a single e2e flow.
61
+ * Includes setup node, optional login, flow-specific test node,
62
+ * optional cleanup, and report node.
63
+ */
64
+ export declare function buildFlowWorkflow(flow: FlowConfig, baseUrl: string, cleanup?: CleanupConfig): Workflow;
65
+ /**
66
+ * Build .env template content for e2e testing.
67
+ */
68
+ export declare function buildE2eEnvTemplate(selections: E2eSelections): string;
69
+ /**
70
+ * Interactive E2E setup wizard — prompts through flow selection,
71
+ * then generates .sweny/e2e/*.yml and .env additions.
72
+ */
73
+ export declare function runE2eInit(): Promise<void>;
74
+ export interface E2eRunOptions {
75
+ file?: string;
76
+ timeout?: number;
77
+ }
78
+ /**
79
+ * Load and execute e2e workflow files from .sweny/e2e/.
80
+ */
81
+ export declare function runE2eRun(options: E2eRunOptions): Promise<void>;
82
+ export {};
@@ -0,0 +1,892 @@
1
+ /**
2
+ * sweny e2e — Generate and run agent-driven end-to-end browser tests.
3
+ *
4
+ * Pure functions for workflow generation + thin @clack/prompts interactive layer.
5
+ * Tests cover the pure functions; the interactive wizard is thin glue.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import * as p from "@clack/prompts";
10
+ import chalk from "chalk";
11
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
12
+ import { execute } from "../executor.js";
13
+ import { ClaudeClient } from "../claude.js";
14
+ import { createSkillMap, configuredSkills } from "../skills/index.js";
15
+ import { consoleLogger } from "../types.js";
16
+ import { validateWorkflow } from "../schema.js";
17
+ // ── Template variable resolution ───────────────────────────────────────
18
+ /**
19
+ * Replace {var_name} placeholders in text with values from the vars map.
20
+ * Unknown variables are left untouched.
21
+ */
22
+ export function resolveTemplateVars(text, vars) {
23
+ return text.replace(/\{(\w+)\}/g, (match, key) => vars[key] ?? match);
24
+ }
25
+ /**
26
+ * Build the template variable map from environment variables.
27
+ * Auto-generates run_id, test_email, test_password.
28
+ * Reads E2E_BASE_URL, E2E_EMAIL, E2E_PASSWORD, and any E2E_* custom vars.
29
+ */
30
+ export function buildE2eVars(env) {
31
+ const runId = env.RUN_ID || Date.now().toString();
32
+ const testEmail = `e2e-${runId}@yourapp.test`;
33
+ const testPassword = `E2eTest!${runId}`;
34
+ const vars = {
35
+ run_id: runId,
36
+ base_url: env.E2E_BASE_URL || "http://localhost:3000",
37
+ test_email: testEmail,
38
+ test_password: testPassword,
39
+ email: env.E2E_EMAIL || testEmail,
40
+ password: env.E2E_PASSWORD || testPassword,
41
+ };
42
+ // Pick up any E2E_* custom vars (strip prefix, lowercase)
43
+ for (const [key, value] of Object.entries(env)) {
44
+ if (key.startsWith("E2E_") && value && !["E2E_BASE_URL", "E2E_EMAIL", "E2E_PASSWORD"].includes(key)) {
45
+ const varName = key.slice(4).toLowerCase();
46
+ vars[varName] = value;
47
+ }
48
+ }
49
+ return vars;
50
+ }
51
+ // ── Shared node builders ───────────────────────────────────────────────
52
+ const TEST_OUTPUT_SCHEMA = {
53
+ type: "object",
54
+ properties: {
55
+ status: { type: "string", enum: ["pass", "fail"] },
56
+ error: { type: "string" },
57
+ },
58
+ required: ["status"],
59
+ };
60
+ const REPORT_OUTPUT_SCHEMA = {
61
+ type: "object",
62
+ properties: {
63
+ total: { type: "number" },
64
+ passed: { type: "number" },
65
+ failed: { type: "number" },
66
+ summary: { type: "string" },
67
+ },
68
+ required: ["total", "passed", "failed", "summary"],
69
+ };
70
+ export function buildSetupNode() {
71
+ return {
72
+ name: "Browser Setup",
73
+ instruction: `You need the agent-browser CLI for browser automation.
74
+ Check if it's installed: which agent-browser
75
+ If the command is not found, install it: npm install -g @anthropic-ai/agent-browser
76
+
77
+ Start the daemon in the background: agent-browser &
78
+ Wait for it to be ready by polling: agent-browser get url
79
+ Retry every 2 seconds, up to 30 seconds. If it doesn't respond after 30 seconds, report status "fail".
80
+
81
+ Once the daemon is ready, report status "ready".
82
+
83
+ IMPORTANT: The agent-browser CLI uses an accessibility tree, not screenshots.
84
+ After navigating to a page, run: agent-browser snapshot
85
+ This returns element references like @e1, @e2, @e3.
86
+ Use those refs with: agent-browser click @e5, agent-browser fill @e7 "text", etc.
87
+
88
+ Available commands:
89
+ - agent-browser open <url> — navigate to URL
90
+ - agent-browser snapshot — get accessibility tree with @refs
91
+ - agent-browser click <ref> — click an element
92
+ - agent-browser fill <ref> <text> — clear input and fill with text
93
+ - agent-browser press <key> — press keyboard key (Enter, Tab, Escape)
94
+ - agent-browser get url — get current page URL
95
+ - agent-browser get text <ref> — get element text content
96
+ - agent-browser screenshot <path> — save screenshot to file
97
+ - agent-browser scroll <direction> <pixels> — scroll page
98
+ - agent-browser scrollintoview <ref> — scroll element into view
99
+ - agent-browser select <ref> <value> — select dropdown option`,
100
+ skills: [],
101
+ output: {
102
+ type: "object",
103
+ properties: {
104
+ status: { type: "string", enum: ["ready", "fail"] },
105
+ },
106
+ required: ["status"],
107
+ },
108
+ };
109
+ }
110
+ export function buildReportNode(testNodeIds) {
111
+ const nodeList = testNodeIds.map((id) => `- ${id}`).join("\n");
112
+ return {
113
+ name: "Test Report",
114
+ instruction: `Compile results from all test nodes into a final report.
115
+
116
+ Test nodes to summarize:
117
+ ${nodeList}
118
+
119
+ For each test node:
120
+ - Report the status (pass/fail)
121
+ - Note any errors or unexpected behavior
122
+
123
+ Count total passed and total failed.
124
+ Format the summary as a single concise paragraph.`,
125
+ skills: [],
126
+ output: REPORT_OUTPUT_SCHEMA,
127
+ };
128
+ }
129
+ export function buildCleanupNode(backend) {
130
+ let instruction;
131
+ switch (backend) {
132
+ case "supabase":
133
+ instruction = `Clean up test data created during this E2E run.
134
+
135
+ Delete any test users matching the pattern e2e-*@yourapp.test using the Supabase Auth Admin API.
136
+ The service role key is available in your environment as SUPABASE_SERVICE_ROLE_KEY.
137
+ The Supabase URL is available as SUPABASE_URL.
138
+
139
+ Use curl to call the Supabase Admin API:
140
+ 1. List users: GET {SUPABASE_URL}/auth/v1/admin/users (with apikey and Authorization: Bearer headers)
141
+ 2. Filter for emails starting with "e2e-" and ending with "@yourapp.test"
142
+ 3. Delete each matching user: DELETE {SUPABASE_URL}/auth/v1/admin/users/{user_id}
143
+
144
+ If no test users are found, that's fine — skip gracefully.
145
+ If the service role key is not available, skip cleanup gracefully.`;
146
+ break;
147
+ case "firebase":
148
+ instruction = `Clean up test data created during this E2E run.
149
+
150
+ Delete any test users matching the pattern e2e-*@yourapp.test using the Firebase Admin SDK or REST API.
151
+ Use the FIREBASE_SERVICE_ACCOUNT_KEY environment variable for authentication.
152
+
153
+ If the service account key is not available, skip cleanup gracefully.`;
154
+ break;
155
+ case "postgres":
156
+ instruction = `Clean up test data created during this E2E run.
157
+
158
+ Connect to the database using DATABASE_URL from the environment.
159
+ Delete any test records matching the pattern e2e-{run_id} or e2e-*@yourapp.test.
160
+
161
+ If DATABASE_URL is not available, skip cleanup gracefully.`;
162
+ break;
163
+ default:
164
+ instruction = `Clean up test data created during this E2E run.
165
+
166
+ Delete any test data matching the pattern e2e-{run_id} or e2e-*@yourapp.test.
167
+ Use whatever API or database tools are available in your environment.
168
+
169
+ If cleanup credentials are not available, skip gracefully.`;
170
+ break;
171
+ }
172
+ return {
173
+ name: "Cleanup Test Data",
174
+ instruction,
175
+ skills: [],
176
+ };
177
+ }
178
+ // ── Flow types that require authentication first ───────────────────────
179
+ const AUTH_REQUIRED_FLOWS = ["purchase", "onboarding", "upgrade", "cancellation"];
180
+ // ── Login node (shared by auth-dependent flows) ────────────────────────
181
+ function buildLoginNode(loginPath) {
182
+ return {
183
+ name: "Test: Login",
184
+ instruction: `Log in to the application.
185
+
186
+ 1. Navigate to {base_url}${loginPath}
187
+ Run: agent-browser open {base_url}${loginPath}
188
+ 2. Take a snapshot to see the login form:
189
+ Run: agent-browser snapshot
190
+ 3. Find the email/username input and fill it:
191
+ Run: agent-browser fill @<ref> "{email}"
192
+ 4. Find the password input and fill it:
193
+ Run: agent-browser fill @<ref> "{password}"
194
+ 5. Click the login/submit button:
195
+ Run: agent-browser click @<ref>
196
+ 6. Wait 3 seconds for login to process
197
+ 7. Take a snapshot to see the result
198
+ 8. Check the URL: agent-browser get url
199
+
200
+ Success: URL no longer contains ${loginPath} (redirected to authenticated page).
201
+ Failure: Still on ${loginPath} or error visible in snapshot.
202
+
203
+ Take a screenshot as evidence:
204
+ Run: agent-browser screenshot results/login-result.png
205
+
206
+ Report status "pass" if login succeeded, "fail" otherwise.`,
207
+ skills: [],
208
+ output: TEST_OUTPUT_SCHEMA,
209
+ };
210
+ }
211
+ // ── Flow-specific test node instructions ───────────────────────────────
212
+ function buildRegistrationInstruction(config) {
213
+ const fields = config.fields?.length ? config.fields.map((f) => `- ${f}`).join("\n") : "- email\n- password";
214
+ const successRedirect = config.successRedirect || "/dashboard";
215
+ return `Test the registration/signup flow.
216
+
217
+ 1. Navigate to {base_url}${config.path}
218
+ Run: agent-browser open {base_url}${config.path}
219
+ 2. Take a snapshot to see the signup form:
220
+ Run: agent-browser snapshot
221
+ 3. Fill in the registration form fields:
222
+ ${fields}
223
+ For email, use: {test_email}
224
+ For password, use: {test_password}
225
+ For name fields, use: E2E Test User
226
+ For other fields, use reasonable test values.
227
+ Use agent-browser fill @<ref> "<value>" for each field.
228
+ 4. Click the submit/signup button:
229
+ Run: agent-browser click @<ref>
230
+ 5. Wait 3 seconds for registration to process
231
+ 6. Take a snapshot to see the result
232
+ 7. Check the URL: agent-browser get url
233
+
234
+ Success: URL contains ${successRedirect} or a welcome/confirmation page.
235
+ Failure: Still on ${config.path} or error visible in snapshot.
236
+
237
+ Take a screenshot as evidence:
238
+ Run: agent-browser screenshot results/registration-result.png
239
+
240
+ Report status "pass" if registration succeeded, "fail" otherwise.`;
241
+ }
242
+ function buildLoginInstruction(config) {
243
+ return `Test the login/authentication flow.
244
+
245
+ 1. Navigate to {base_url}${config.path}
246
+ Run: agent-browser open {base_url}${config.path}
247
+ 2. Take a snapshot to see the login form:
248
+ Run: agent-browser snapshot
249
+ 3. Find the email/username input and fill it:
250
+ Run: agent-browser fill @<ref> "{email}"
251
+ 4. Find the password input and fill it:
252
+ Run: agent-browser fill @<ref> "{password}"
253
+ 5. Click the login/submit button:
254
+ Run: agent-browser click @<ref>
255
+ 6. Wait 3 seconds for login to process
256
+ 7. Take a snapshot to see the result
257
+ 8. Check the URL: agent-browser get url
258
+
259
+ Success: URL no longer contains ${config.path} (redirected to authenticated page).
260
+ Failure: Still on ${config.path} or error visible in snapshot.
261
+
262
+ Take a screenshot as evidence:
263
+ Run: agent-browser screenshot results/login-result.png
264
+
265
+ Report status "pass" if login succeeded, "fail" otherwise.`;
266
+ }
267
+ function buildPurchaseInstruction(config) {
268
+ const provider = config.paymentProvider || "the payment provider";
269
+ return `Test the purchase/checkout flow.
270
+ The browser should already be logged in from the login step.
271
+
272
+ 1. Navigate to {base_url}${config.path}
273
+ Run: agent-browser open {base_url}${config.path}
274
+ 2. Take a snapshot to see the pricing/product page:
275
+ Run: agent-browser snapshot
276
+ 3. Find a purchase/subscribe/buy button and click it:
277
+ Run: agent-browser click @<ref>
278
+ If the button is not visible, scroll down:
279
+ Run: agent-browser scroll down 500
280
+ Then snapshot again.
281
+ 4. Wait 5 seconds for the checkout redirect
282
+ 5. Check the URL: agent-browser get url
283
+
284
+ ${config.successCriteria || `Success: URL redirects to ${provider} checkout page or a confirmation page.\nFailure: Still on ${config.path} or error visible.`}
285
+
286
+ Take a screenshot as evidence:
287
+ Run: agent-browser screenshot results/purchase-result.png
288
+
289
+ Report status "pass" if checkout initiated successfully, "fail" otherwise.`;
290
+ }
291
+ function buildOnboardingInstruction(config) {
292
+ return `Test the onboarding flow.
293
+ The browser should already be logged in from the login step.
294
+
295
+ 1. Navigate to {base_url}${config.path}
296
+ Run: agent-browser open {base_url}${config.path}
297
+ 2. Take a snapshot to see the onboarding page:
298
+ Run: agent-browser snapshot
299
+ 3. Follow the onboarding steps:
300
+ - Read the snapshot to understand each step
301
+ - Fill any required fields using agent-browser fill
302
+ - Click next/continue buttons using agent-browser click
303
+ - Take a snapshot after each step to see the result
304
+ 4. After completing all onboarding steps, check the URL:
305
+ Run: agent-browser get url
306
+
307
+ ${config.successCriteria || "Success: Onboarding completes and redirects to the main app.\nFailure: Stuck on an onboarding step or error visible."}
308
+
309
+ Take a screenshot as evidence:
310
+ Run: agent-browser screenshot results/onboarding-result.png
311
+
312
+ Report status "pass" if onboarding completed, "fail" otherwise.`;
313
+ }
314
+ function buildUpgradeInstruction(config) {
315
+ return `Test the plan upgrade flow.
316
+ The browser should already be logged in from the login step.
317
+
318
+ 1. Navigate to {base_url}${config.path}
319
+ Run: agent-browser open {base_url}${config.path}
320
+ 2. Take a snapshot to see the upgrade/plan page:
321
+ Run: agent-browser snapshot
322
+ 3. Find and click the upgrade button or select a higher plan:
323
+ Run: agent-browser click @<ref>
324
+ 4. If there's a confirmation dialog, confirm it:
325
+ Run: agent-browser snapshot
326
+ Then: agent-browser click @<confirm-ref>
327
+ 5. Wait 3 seconds for the upgrade to process
328
+ 6. Take a snapshot to see the result
329
+
330
+ ${config.successCriteria || "Success: Upgrade confirmation shown or plan updated.\nFailure: Error visible or still on same page without change."}
331
+
332
+ Take a screenshot as evidence:
333
+ Run: agent-browser screenshot results/upgrade-result.png
334
+
335
+ Report status "pass" if upgrade succeeded, "fail" otherwise.`;
336
+ }
337
+ function buildCancellationInstruction(config) {
338
+ return `Test the cancellation flow.
339
+ The browser should already be logged in from the login step.
340
+
341
+ 1. Navigate to {base_url}${config.path}
342
+ Run: agent-browser open {base_url}${config.path}
343
+ 2. Take a snapshot to see the cancellation/account page:
344
+ Run: agent-browser snapshot
345
+ 3. Find and click the cancel button:
346
+ Run: agent-browser click @<ref>
347
+ 4. If there's a confirmation dialog, confirm the cancellation:
348
+ Run: agent-browser snapshot
349
+ Then: agent-browser click @<confirm-ref>
350
+ 5. Wait 3 seconds for cancellation to process
351
+ 6. Take a snapshot to see the result
352
+
353
+ ${config.successCriteria || "Success: Cancellation confirmed, subscription/account status updated.\nFailure: Error visible or cancellation not processed."}
354
+
355
+ Take a screenshot as evidence:
356
+ Run: agent-browser screenshot results/cancellation-result.png
357
+
358
+ Report status "pass" if cancellation succeeded, "fail" otherwise.`;
359
+ }
360
+ function buildCustomInstruction(config) {
361
+ const description = config.description || "Perform the custom test flow";
362
+ const criteria = config.successCriteria || "The expected outcome is achieved without errors";
363
+ return `Test a custom flow: ${description}
364
+
365
+ 1. Navigate to {base_url}${config.path}
366
+ Run: agent-browser open {base_url}${config.path}
367
+ 2. Take a snapshot to see the page:
368
+ Run: agent-browser snapshot
369
+ 3. Follow the flow described above. Use the accessibility tree from snapshots to find and interact with elements:
370
+ - agent-browser click @<ref> to click
371
+ - agent-browser fill @<ref> "<text>" to fill inputs
372
+ - agent-browser press Enter/Tab to press keys
373
+ - agent-browser snapshot to see updated page state
374
+ 4. After completing the flow, take a final snapshot.
375
+
376
+ Success criteria: ${criteria}
377
+
378
+ Take a screenshot as evidence:
379
+ Run: agent-browser screenshot results/custom-result.png
380
+
381
+ Report status "pass" if the success criteria are met, "fail" otherwise.`;
382
+ }
383
+ /**
384
+ * Build the test nodes for a given flow type.
385
+ * Auth-dependent flows (purchase, onboarding, upgrade, cancellation)
386
+ * automatically include a login node.
387
+ */
388
+ export function buildFlowNodes(config) {
389
+ const nodes = {};
390
+ const testNodeIds = [];
391
+ const needsAuth = AUTH_REQUIRED_FLOWS.includes(config.type);
392
+ // Add login node for auth-dependent flows
393
+ if (needsAuth) {
394
+ nodes.login = buildLoginNode("/login");
395
+ testNodeIds.push("login");
396
+ }
397
+ // Build the flow-specific test instruction
398
+ let instruction;
399
+ switch (config.type) {
400
+ case "registration":
401
+ instruction = buildRegistrationInstruction(config);
402
+ break;
403
+ case "login":
404
+ instruction = buildLoginInstruction(config);
405
+ break;
406
+ case "purchase":
407
+ instruction = buildPurchaseInstruction(config);
408
+ break;
409
+ case "onboarding":
410
+ instruction = buildOnboardingInstruction(config);
411
+ break;
412
+ case "upgrade":
413
+ instruction = buildUpgradeInstruction(config);
414
+ break;
415
+ case "cancellation":
416
+ instruction = buildCancellationInstruction(config);
417
+ break;
418
+ case "custom":
419
+ instruction = buildCustomInstruction(config);
420
+ break;
421
+ }
422
+ const nodeId = `test_${config.type}`;
423
+ nodes[nodeId] = {
424
+ name: `Test: ${config.type.charAt(0).toUpperCase() + config.type.slice(1)}`,
425
+ instruction,
426
+ skills: [],
427
+ output: TEST_OUTPUT_SCHEMA,
428
+ };
429
+ testNodeIds.push(nodeId);
430
+ return { nodes, testNodeIds };
431
+ }
432
+ // ── Workflow assembler ─────────────────────────────────────────────────
433
+ /**
434
+ * Build a complete Workflow object for a single e2e flow.
435
+ * Includes setup node, optional login, flow-specific test node,
436
+ * optional cleanup, and report node.
437
+ */
438
+ export function buildFlowWorkflow(flow, baseUrl, cleanup) {
439
+ const { nodes: flowNodes, testNodeIds } = buildFlowNodes(flow);
440
+ const nodes = {};
441
+ const edges = [];
442
+ // 1. Setup node (always first)
443
+ nodes.setup = buildSetupNode();
444
+ // 2. Flow nodes (login + test)
445
+ for (const [id, node] of Object.entries(flowNodes)) {
446
+ nodes[id] = node;
447
+ }
448
+ // 3. Build edges based on flow structure
449
+ const firstTestNode = testNodeIds[0];
450
+ const lastTestNode = testNodeIds[testNodeIds.length - 1];
451
+ // Setup → first test (or login) on success
452
+ edges.push({ from: "setup", to: firstTestNode, when: "setup status is ready" });
453
+ edges.push({ from: "setup", to: "report", when: "setup status is fail" });
454
+ // If there's a login node + test node, chain them
455
+ if (testNodeIds.length > 1) {
456
+ edges.push({
457
+ from: testNodeIds[0],
458
+ to: testNodeIds[1],
459
+ when: `${testNodeIds[0]} status is pass`,
460
+ });
461
+ edges.push({
462
+ from: testNodeIds[0],
463
+ to: "report",
464
+ when: `${testNodeIds[0]} status is fail`,
465
+ });
466
+ }
467
+ // 4. Cleanup node (optional)
468
+ if (cleanup?.enabled) {
469
+ nodes.cleanup = buildCleanupNode(cleanup.backend);
470
+ edges.push({ from: lastTestNode, to: "cleanup" });
471
+ edges.push({ from: "cleanup", to: "report" });
472
+ }
473
+ else {
474
+ edges.push({ from: lastTestNode, to: "report" });
475
+ }
476
+ // 5. Report node (always last)
477
+ nodes.report = buildReportNode(testNodeIds);
478
+ const typeName = flow.type.charAt(0).toUpperCase() + flow.type.slice(1);
479
+ return {
480
+ id: `e2e-${flow.type}`,
481
+ name: `E2E: ${typeName}`,
482
+ description: `End-to-end test for ${flow.type} flow`,
483
+ entry: "setup",
484
+ nodes,
485
+ edges,
486
+ };
487
+ }
488
+ // ── Env template builder ───────────────────────────────────────────────
489
+ const CLEANUP_ENV_VARS = {
490
+ supabase: [
491
+ { key: "SUPABASE_URL", hint: "e.g. https://xxxx.supabase.co" },
492
+ { key: "SUPABASE_SERVICE_ROLE_KEY", hint: "Settings > API > service_role key" },
493
+ ],
494
+ firebase: [{ key: "FIREBASE_SERVICE_ACCOUNT_KEY", hint: "Firebase Console > Project Settings > Service Accounts" }],
495
+ postgres: [{ key: "DATABASE_URL", hint: "e.g. postgresql://user:pass@host:5432/db" }],
496
+ };
497
+ /**
498
+ * Build .env template content for e2e testing.
499
+ */
500
+ export function buildE2eEnvTemplate(selections) {
501
+ const lines = [];
502
+ lines.push("# E2E Testing — SWEny");
503
+ lines.push("# Fill in values, then run: sweny e2e run");
504
+ lines.push("");
505
+ lines.push(`E2E_BASE_URL=${selections.baseUrl}`);
506
+ lines.push("");
507
+ // Add E2E_EMAIL/PASSWORD if any flow needs auth
508
+ const needsAuth = selections.flows.some((f) => AUTH_REQUIRED_FLOWS.includes(f.type) || f.type === "login");
509
+ if (needsAuth) {
510
+ lines.push("# Test account credentials (for login-dependent flows)");
511
+ lines.push("E2E_EMAIL=");
512
+ lines.push("E2E_PASSWORD=");
513
+ lines.push("");
514
+ }
515
+ // Cleanup env vars
516
+ if (selections.cleanup.enabled && selections.cleanup.backend) {
517
+ const vars = CLEANUP_ENV_VARS[selections.cleanup.backend];
518
+ if (vars) {
519
+ lines.push("# Cleanup");
520
+ for (const v of vars) {
521
+ if (v.hint)
522
+ lines.push(`# ${v.hint}`);
523
+ lines.push(`${v.key}=`);
524
+ }
525
+ lines.push("");
526
+ }
527
+ }
528
+ return lines.join("\n");
529
+ }
530
+ // ── Flow type labels ───────────────────────────────────────────────────
531
+ const FLOW_TYPE_LABELS = {
532
+ registration: "Registration / Signup",
533
+ login: "Login / Auth",
534
+ purchase: "Purchase / Checkout",
535
+ onboarding: "Onboarding",
536
+ upgrade: "User Upgrade / Plan Change",
537
+ cancellation: "Cancellation",
538
+ custom: "Custom (describe it)",
539
+ };
540
+ // ── Cancel helper ──────────────────────────────────────────────────────
541
+ function cancel() {
542
+ p.cancel("Setup cancelled.");
543
+ process.exit(0);
544
+ }
545
+ // ── Per-flow follow-up questions ───────────────────────────────────────
546
+ async function askFlowQuestions(type) {
547
+ const defaultPaths = {
548
+ registration: "/signup",
549
+ login: "/login",
550
+ purchase: "/pricing",
551
+ onboarding: "/onboarding",
552
+ upgrade: "/upgrade",
553
+ cancellation: "/cancel",
554
+ custom: "/",
555
+ };
556
+ const flowPath = await p.text({
557
+ message: `URL path for ${FLOW_TYPE_LABELS[type]}?`,
558
+ placeholder: defaultPaths[type],
559
+ validate: (v) => (!v?.startsWith("/") ? "Path must start with /" : undefined),
560
+ });
561
+ if (p.isCancel(flowPath))
562
+ cancel();
563
+ const config = { type, path: flowPath };
564
+ if (type === "registration") {
565
+ const fieldsRaw = await p.text({
566
+ message: "Required form fields (comma-separated)?",
567
+ placeholder: "email, password, name",
568
+ initialValue: "email, password, name",
569
+ });
570
+ if (p.isCancel(fieldsRaw))
571
+ cancel();
572
+ config.fields = fieldsRaw
573
+ .split(",")
574
+ .map((f) => f.trim())
575
+ .filter(Boolean);
576
+ const successRedirect = await p.text({
577
+ message: "Success redirect path?",
578
+ placeholder: "/dashboard",
579
+ initialValue: "/dashboard",
580
+ });
581
+ if (p.isCancel(successRedirect))
582
+ cancel();
583
+ config.successRedirect = successRedirect;
584
+ }
585
+ if (type === "purchase") {
586
+ const provider = await p.text({
587
+ message: "Payment provider?",
588
+ placeholder: "Stripe",
589
+ initialValue: "Stripe",
590
+ });
591
+ if (p.isCancel(provider))
592
+ cancel();
593
+ config.paymentProvider = provider;
594
+ }
595
+ if (type === "custom") {
596
+ const desc = await p.text({
597
+ message: "Describe the flow in a sentence or two:",
598
+ validate: (v) => (!v ? "Description is required" : undefined),
599
+ });
600
+ if (p.isCancel(desc))
601
+ cancel();
602
+ config.description = desc;
603
+ }
604
+ if (type !== "registration") {
605
+ const criteria = await p.text({
606
+ message: "What does success look like?",
607
+ placeholder: "Redirected to confirmation page",
608
+ });
609
+ if (p.isCancel(criteria))
610
+ cancel();
611
+ if (criteria)
612
+ config.successCriteria = criteria;
613
+ }
614
+ return config;
615
+ }
616
+ // ── Interactive wizard ─────────────────────────────────────────────────
617
+ /**
618
+ * Interactive E2E setup wizard — prompts through flow selection,
619
+ * then generates .sweny/e2e/*.yml and .env additions.
620
+ */
621
+ export async function runE2eInit() {
622
+ const cwd = process.cwd();
623
+ // ── Screen 1: Intro ──────────────────────────────────────────
624
+ p.intro("Let's set up end-to-end testing for your app");
625
+ // Check for existing files
626
+ const e2eDir = path.join(cwd, ".sweny", "e2e");
627
+ if (fs.existsSync(e2eDir)) {
628
+ const files = fs.readdirSync(e2eDir).filter((f) => f.endsWith(".yml"));
629
+ if (files.length > 0) {
630
+ const overwrite = await p.confirm({
631
+ message: `.sweny/e2e/ already has ${files.length} workflow file(s). Continue and overwrite?`,
632
+ initialValue: false,
633
+ });
634
+ if (p.isCancel(overwrite))
635
+ cancel();
636
+ if (!overwrite) {
637
+ p.cancel("Setup cancelled — existing files preserved.");
638
+ process.exit(0);
639
+ }
640
+ }
641
+ }
642
+ // ── Screen 2: Flow type selection ────────────────────────────
643
+ const flowTypes = await p.multiselect({
644
+ message: "What flows do you want to test?",
645
+ options: Object.entries(FLOW_TYPE_LABELS).map(([value, label]) => ({
646
+ value,
647
+ label,
648
+ })),
649
+ required: true,
650
+ });
651
+ if (p.isCancel(flowTypes))
652
+ cancel();
653
+ const selectedTypes = flowTypes;
654
+ // ── Screen 3: Per-flow follow-ups ────────────────────────────
655
+ const flows = [];
656
+ for (const type of selectedTypes) {
657
+ p.log.step(`Configure: ${FLOW_TYPE_LABELS[type]}`);
658
+ const config = await askFlowQuestions(type);
659
+ flows.push(config);
660
+ }
661
+ // ── Screen 4: Base URL ───────────────────────────────────────
662
+ const baseUrl = await p.text({
663
+ message: "App URL for testing?",
664
+ placeholder: "http://localhost:3000",
665
+ initialValue: "http://localhost:3000",
666
+ validate: (v) => {
667
+ try {
668
+ new URL(v);
669
+ return undefined;
670
+ }
671
+ catch {
672
+ return "Must be a valid URL";
673
+ }
674
+ },
675
+ });
676
+ if (p.isCancel(baseUrl))
677
+ cancel();
678
+ // ── Screen 5: Cleanup ───────────────────────────────────────
679
+ const wantCleanup = await p.confirm({
680
+ message: "Auto-cleanup test data after runs?",
681
+ initialValue: false,
682
+ });
683
+ if (p.isCancel(wantCleanup))
684
+ cancel();
685
+ let cleanup = { enabled: false };
686
+ if (wantCleanup) {
687
+ const backend = await p.select({
688
+ message: "What's your backend?",
689
+ options: [
690
+ { value: "supabase", label: "Supabase" },
691
+ { value: "firebase", label: "Firebase" },
692
+ { value: "postgres", label: "PostgreSQL" },
693
+ { value: "api", label: "REST API" },
694
+ { value: "other", label: "Other" },
695
+ ],
696
+ });
697
+ if (p.isCancel(backend))
698
+ cancel();
699
+ cleanup = { enabled: true, backend: backend };
700
+ }
701
+ const selections = {
702
+ flows,
703
+ baseUrl: baseUrl,
704
+ cleanup,
705
+ };
706
+ // ── Screen 6: Summary ───────────────────────────────────────
707
+ const fileList = flows.map((f) => `.sweny/e2e/${f.type}.yml`).join(", ");
708
+ p.log.info(`Will generate: ${chalk.cyan(fileList)}`);
709
+ if (cleanup.enabled) {
710
+ p.log.info(`Cleanup: ${chalk.cyan(cleanup.backend)} backend`);
711
+ }
712
+ const proceed = await p.confirm({
713
+ message: "Generate files?",
714
+ initialValue: true,
715
+ });
716
+ if (p.isCancel(proceed) || !proceed)
717
+ cancel();
718
+ // ── Screen 7: Write files ───────────────────────────────────
719
+ fs.mkdirSync(e2eDir, { recursive: true });
720
+ // Generate workflow files
721
+ for (const flow of flows) {
722
+ const workflow = buildFlowWorkflow(flow, baseUrl, cleanup);
723
+ const yamlContent = stringifyYaml(workflow, { indent: 2, lineWidth: 120 });
724
+ const filePath = path.join(e2eDir, `${flow.type}.yml`);
725
+ fs.writeFileSync(filePath, yamlContent, "utf-8");
726
+ p.log.success(`Created ${chalk.cyan(filePath)}`);
727
+ }
728
+ // Append to .env
729
+ const envContent = buildE2eEnvTemplate(selections);
730
+ const envPath = path.join(cwd, ".env");
731
+ if (fs.existsSync(envPath)) {
732
+ const existing = fs.readFileSync(envPath, "utf-8");
733
+ if (!existing.includes("E2E_BASE_URL")) {
734
+ fs.appendFileSync(envPath, "\n" + envContent, "utf-8");
735
+ p.log.success(`Appended E2E vars to ${chalk.cyan(".env")}`);
736
+ }
737
+ else {
738
+ p.log.warn(".env already has E2E vars — skipped");
739
+ }
740
+ }
741
+ else {
742
+ fs.writeFileSync(envPath, envContent, "utf-8");
743
+ p.log.success(`Created ${chalk.cyan(".env")}`);
744
+ }
745
+ // ── Next steps ──────────────────────────────────────────────
746
+ p.outro("E2E setup complete!");
747
+ console.log(chalk.dim("\n Next steps:\n"));
748
+ console.log(chalk.dim(" 1. Fill in your .env values"));
749
+ if (selections.flows.some((f) => AUTH_REQUIRED_FLOWS.includes(f.type) || f.type === "login")) {
750
+ console.log(chalk.dim(" 2. Set E2E_EMAIL and E2E_PASSWORD to a test account"));
751
+ console.log(chalk.dim(` 3. Run: ${chalk.cyan("sweny e2e run")}\n`));
752
+ }
753
+ else {
754
+ console.log(chalk.dim(` 2. Run: ${chalk.cyan("sweny e2e run")}\n`));
755
+ }
756
+ }
757
+ // ── Timeout helper ─────────────────────────────────────────────────────
758
+ function withTimeout(promise, ms, label) {
759
+ return new Promise((resolve, reject) => {
760
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms);
761
+ promise
762
+ .then(resolve)
763
+ .catch(reject)
764
+ .finally(() => clearTimeout(timer));
765
+ });
766
+ }
767
+ /**
768
+ * Load and execute e2e workflow files from .sweny/e2e/.
769
+ */
770
+ export async function runE2eRun(options) {
771
+ const cwd = process.cwd();
772
+ const timeoutMs = options.timeout || 15 * 60 * 1000;
773
+ // 1. Discover workflow files
774
+ let files;
775
+ if (options.file) {
776
+ const filePath = path.resolve(cwd, options.file);
777
+ if (!fs.existsSync(filePath)) {
778
+ console.error(chalk.red(` File not found: ${options.file}`));
779
+ process.exit(1);
780
+ }
781
+ files = [filePath];
782
+ }
783
+ else {
784
+ const e2eDir = path.join(cwd, ".sweny", "e2e");
785
+ if (!fs.existsSync(e2eDir)) {
786
+ console.error(chalk.red(" No .sweny/e2e/ directory found."));
787
+ console.error(chalk.dim(" Run 'sweny e2e init' to set up e2e testing."));
788
+ process.exit(1);
789
+ }
790
+ files = fs
791
+ .readdirSync(e2eDir)
792
+ .filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
793
+ .sort()
794
+ .map((f) => path.join(e2eDir, f));
795
+ if (files.length === 0) {
796
+ console.error(chalk.red(" No workflow files found in .sweny/e2e/"));
797
+ console.error(chalk.dim(" Run 'sweny e2e init' to set up e2e testing."));
798
+ process.exit(1);
799
+ }
800
+ }
801
+ // 2. Build template vars
802
+ const vars = buildE2eVars(process.env);
803
+ // 3. Banner
804
+ console.log(`\n${"─".repeat(50)}`);
805
+ console.log("E2E Test Run");
806
+ console.log(` Target: ${vars.base_url}`);
807
+ console.log(` Run ID: ${vars.run_id}`);
808
+ console.log(`${"─".repeat(50)}\n`);
809
+ // 4. Execute each workflow
810
+ const runResults = [];
811
+ for (const file of files) {
812
+ const fileName = path.basename(file);
813
+ console.log(`▶ ${chalk.bold(fileName)}`);
814
+ let workflow;
815
+ try {
816
+ const raw = parseYaml(fs.readFileSync(file, "utf-8"));
817
+ const errors = validateWorkflow(raw);
818
+ if (errors.length > 0) {
819
+ throw new Error(`Invalid workflow:\n${errors.map((e) => ` ${e.message}`).join("\n")}`);
820
+ }
821
+ workflow = raw;
822
+ }
823
+ catch (err) {
824
+ console.error(chalk.red(` Error loading ${fileName}: ${err instanceof Error ? err.message : String(err)}`));
825
+ runResults.push({ file: fileName, passed: false });
826
+ continue;
827
+ }
828
+ // Replace template vars in node instructions
829
+ for (const node of Object.values(workflow.nodes)) {
830
+ node.instruction = resolveTemplateVars(node.instruction, vars);
831
+ }
832
+ // Build skills + Claude client
833
+ const skills = createSkillMap(configuredSkills(process.env, cwd));
834
+ const claude = new ClaudeClient({
835
+ maxTurns: 80,
836
+ cwd,
837
+ logger: consoleLogger,
838
+ });
839
+ // Progress observer
840
+ const nodeEnterTimes = new Map();
841
+ const isTTY = process.stderr.isTTY ?? false;
842
+ const observer = (event) => {
843
+ switch (event.type) {
844
+ case "node:enter":
845
+ nodeEnterTimes.set(event.node, Date.now());
846
+ process.stderr.write(` ${chalk.dim("○")} ${chalk.dim(event.node)}…\n`);
847
+ break;
848
+ case "node:exit": {
849
+ const icon = event.result.status === "success"
850
+ ? chalk.green("✓")
851
+ : event.result.status === "skipped"
852
+ ? chalk.dim("−")
853
+ : chalk.red("✗");
854
+ const enterTime = nodeEnterTimes.get(event.node) ?? Date.now();
855
+ const elapsed = ((Date.now() - enterTime) / 1000).toFixed(1);
856
+ const status = event.result.data?.status || event.result.status;
857
+ if (isTTY) {
858
+ process.stderr.write(`\x1B[1A\x1B[2K ${icon} ${event.node} — ${status} (${elapsed}s)\n`);
859
+ }
860
+ else {
861
+ process.stderr.write(` ${icon} ${event.node} — ${status} (${elapsed}s)\n`);
862
+ }
863
+ break;
864
+ }
865
+ }
866
+ };
867
+ try {
868
+ const { results } = await withTimeout(execute(workflow, { run_id: vars.run_id, base_url: vars.base_url }, { skills, claude, observer, logger: consoleLogger }), timeoutMs, `Workflow ${workflow.name}`);
869
+ // Extract report
870
+ const report = results.get("report");
871
+ if (report?.data) {
872
+ console.log(` ${report.data.passed}/${report.data.total} tests passed`);
873
+ if (report.data.summary) {
874
+ console.log(chalk.dim(` ${report.data.summary}`));
875
+ }
876
+ }
877
+ const hasFailed = [...results.values()].some((r) => r.status === "failed" || r.data?.status === "fail");
878
+ runResults.push({ file: fileName, passed: !hasFailed });
879
+ }
880
+ catch (err) {
881
+ console.error(chalk.red(` Error: ${err instanceof Error ? err.message : String(err)}`));
882
+ runResults.push({ file: fileName, passed: false });
883
+ }
884
+ console.log("");
885
+ }
886
+ // 5. Summary
887
+ const passed = runResults.filter((r) => r.passed).length;
888
+ const total = runResults.length;
889
+ const summaryColor = passed === total ? chalk.green : chalk.red;
890
+ console.log(summaryColor(`Results: ${passed}/${total} workflows passed`));
891
+ process.exit(passed === total ? 0 : 1);
892
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,355 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resolveTemplateVars, buildE2eVars, buildSetupNode, buildReportNode, buildCleanupNode, buildFlowNodes, buildFlowWorkflow, buildE2eEnvTemplate, } from "./e2e.js";
3
+ import { workflowZ, validateWorkflow } from "../schema.js";
4
+ describe("resolveTemplateVars", () => {
5
+ it("replaces known variables", () => {
6
+ const result = resolveTemplateVars("Navigate to {base_url}/signup", {
7
+ base_url: "http://localhost:3000",
8
+ });
9
+ expect(result).toBe("Navigate to http://localhost:3000/signup");
10
+ });
11
+ it("leaves unknown variables untouched", () => {
12
+ const result = resolveTemplateVars("Hello {unknown}", {});
13
+ expect(result).toBe("Hello {unknown}");
14
+ });
15
+ it("replaces multiple occurrences of the same variable", () => {
16
+ const result = resolveTemplateVars("{base_url} and {base_url}", {
17
+ base_url: "http://localhost:3000",
18
+ });
19
+ expect(result).toBe("http://localhost:3000 and http://localhost:3000");
20
+ });
21
+ it("replaces multiple different variables", () => {
22
+ const result = resolveTemplateVars("email: {test_email}, pw: {test_password}", {
23
+ test_email: "e2e-123@app.test",
24
+ test_password: "E2eTest!123",
25
+ });
26
+ expect(result).toBe("email: e2e-123@app.test, pw: E2eTest!123");
27
+ });
28
+ it("handles empty string input", () => {
29
+ expect(resolveTemplateVars("", { foo: "bar" })).toBe("");
30
+ });
31
+ });
32
+ describe("buildE2eVars", () => {
33
+ it("auto-generates run_id when not in env", () => {
34
+ const vars = buildE2eVars({});
35
+ expect(vars.run_id).toMatch(/^\d+$/);
36
+ });
37
+ it("uses RUN_ID from env when provided", () => {
38
+ const vars = buildE2eVars({ RUN_ID: "custom-42" });
39
+ expect(vars.run_id).toBe("custom-42");
40
+ });
41
+ it("auto-generates test_email from run_id", () => {
42
+ const vars = buildE2eVars({ RUN_ID: "12345" });
43
+ expect(vars.test_email).toBe("e2e-12345@yourapp.test");
44
+ });
45
+ it("auto-generates test_password from run_id", () => {
46
+ const vars = buildE2eVars({ RUN_ID: "12345" });
47
+ expect(vars.test_password).toBe("E2eTest!12345");
48
+ });
49
+ it("reads base_url from E2E_BASE_URL", () => {
50
+ const vars = buildE2eVars({ E2E_BASE_URL: "https://myapp.com" });
51
+ expect(vars.base_url).toBe("https://myapp.com");
52
+ });
53
+ it("defaults base_url to http://localhost:3000", () => {
54
+ const vars = buildE2eVars({});
55
+ expect(vars.base_url).toBe("http://localhost:3000");
56
+ });
57
+ it("reads email/password from E2E_EMAIL and E2E_PASSWORD", () => {
58
+ const vars = buildE2eVars({
59
+ E2E_EMAIL: "test@example.com",
60
+ E2E_PASSWORD: "secret",
61
+ });
62
+ expect(vars.email).toBe("test@example.com");
63
+ expect(vars.password).toBe("secret");
64
+ });
65
+ it("falls back email/password to test_email/test_password when not in env", () => {
66
+ const vars = buildE2eVars({ RUN_ID: "99" });
67
+ expect(vars.email).toBe(vars.test_email);
68
+ expect(vars.password).toBe(vars.test_password);
69
+ });
70
+ it("picks up E2E_* custom vars", () => {
71
+ const vars = buildE2eVars({ E2E_API_KEY: "abc123" });
72
+ expect(vars.api_key).toBe("abc123");
73
+ });
74
+ });
75
+ describe("buildSetupNode", () => {
76
+ it("returns a node with agent-browser install instructions", () => {
77
+ const node = buildSetupNode();
78
+ expect(node.name).toBe("Browser Setup");
79
+ expect(node.instruction).toContain("agent-browser");
80
+ expect(node.instruction).toContain("npm install -g");
81
+ expect(node.skills).toEqual([]);
82
+ expect(node.output).toBeDefined();
83
+ });
84
+ it("includes daemon startup and readiness polling", () => {
85
+ const node = buildSetupNode();
86
+ expect(node.instruction).toContain("agent-browser &");
87
+ expect(node.instruction).toContain("agent-browser get url");
88
+ });
89
+ it("output schema has status enum [ready, fail]", () => {
90
+ const node = buildSetupNode();
91
+ expect(node.output).toEqual({
92
+ type: "object",
93
+ properties: {
94
+ status: { type: "string", enum: ["ready", "fail"] },
95
+ },
96
+ required: ["status"],
97
+ });
98
+ });
99
+ });
100
+ describe("buildReportNode", () => {
101
+ it("references the test node IDs in its instruction", () => {
102
+ const node = buildReportNode(["test_registration", "test_login"]);
103
+ expect(node.instruction).toContain("test_registration");
104
+ expect(node.instruction).toContain("test_login");
105
+ });
106
+ it("output schema has total, passed, failed, summary", () => {
107
+ const node = buildReportNode(["test_x"]);
108
+ expect(node.output).toEqual({
109
+ type: "object",
110
+ properties: {
111
+ total: { type: "number" },
112
+ passed: { type: "number" },
113
+ failed: { type: "number" },
114
+ summary: { type: "string" },
115
+ },
116
+ required: ["total", "passed", "failed", "summary"],
117
+ });
118
+ });
119
+ });
120
+ describe("buildCleanupNode", () => {
121
+ it("includes supabase-specific instructions for supabase backend", () => {
122
+ const node = buildCleanupNode("supabase");
123
+ expect(node.instruction).toContain("Supabase");
124
+ expect(node.instruction).toContain("e2e-");
125
+ expect(node.instruction).toContain("SUPABASE_SERVICE_ROLE_KEY");
126
+ });
127
+ it("includes firebase-specific instructions for firebase backend", () => {
128
+ const node = buildCleanupNode("firebase");
129
+ expect(node.instruction).toContain("Firebase");
130
+ });
131
+ it("includes generic instructions for other backends", () => {
132
+ const node = buildCleanupNode("other");
133
+ expect(node.instruction).toContain("e2e-{run_id}");
134
+ });
135
+ it("has no output schema", () => {
136
+ const node = buildCleanupNode("supabase");
137
+ expect(node.output).toBeUndefined();
138
+ });
139
+ });
140
+ describe("buildFlowNodes", () => {
141
+ describe("registration", () => {
142
+ it("returns a test_registration node", () => {
143
+ const { nodes, testNodeIds } = buildFlowNodes({
144
+ type: "registration",
145
+ path: "/signup",
146
+ fields: ["email", "password", "name"],
147
+ successRedirect: "/dashboard",
148
+ });
149
+ expect(nodes.test_registration).toBeDefined();
150
+ expect(nodes.test_registration.instruction).toContain("/signup");
151
+ expect(nodes.test_registration.instruction).toContain("{test_email}");
152
+ expect(nodes.test_registration.instruction).toContain("{test_password}");
153
+ expect(nodes.test_registration.instruction).toContain("agent-browser");
154
+ expect(testNodeIds).toEqual(["test_registration"]);
155
+ });
156
+ it("does not include a login node", () => {
157
+ const { nodes } = buildFlowNodes({ type: "registration", path: "/signup" });
158
+ expect(nodes.login).toBeUndefined();
159
+ });
160
+ it("includes field names in instruction", () => {
161
+ const { nodes } = buildFlowNodes({
162
+ type: "registration",
163
+ path: "/signup",
164
+ fields: ["email", "password", "name", "company"],
165
+ });
166
+ expect(nodes.test_registration.instruction).toContain("company");
167
+ });
168
+ });
169
+ describe("login", () => {
170
+ it("returns a test_login node", () => {
171
+ const { nodes, testNodeIds } = buildFlowNodes({ type: "login", path: "/login" });
172
+ expect(nodes.test_login).toBeDefined();
173
+ expect(nodes.test_login.instruction).toContain("/login");
174
+ expect(nodes.test_login.instruction).toContain("{email}");
175
+ expect(nodes.test_login.instruction).toContain("{password}");
176
+ expect(testNodeIds).toEqual(["test_login"]);
177
+ });
178
+ });
179
+ describe("purchase (auth-dependent)", () => {
180
+ it("returns both login and test_purchase nodes", () => {
181
+ const { nodes, testNodeIds } = buildFlowNodes({
182
+ type: "purchase",
183
+ path: "/pricing",
184
+ paymentProvider: "Stripe",
185
+ });
186
+ expect(nodes.login).toBeDefined();
187
+ expect(nodes.test_purchase).toBeDefined();
188
+ expect(nodes.login.instruction).toContain("{email}");
189
+ expect(nodes.test_purchase.instruction).toContain("/pricing");
190
+ expect(nodes.test_purchase.instruction).toContain("Stripe");
191
+ expect(testNodeIds).toEqual(["login", "test_purchase"]);
192
+ });
193
+ });
194
+ describe("custom", () => {
195
+ it("embeds the user description in the instruction", () => {
196
+ const { nodes } = buildFlowNodes({
197
+ type: "custom",
198
+ path: "/admin",
199
+ description: "Test the admin dashboard data export feature",
200
+ successCriteria: "CSV file downloads successfully",
201
+ });
202
+ expect(nodes.test_custom).toBeDefined();
203
+ expect(nodes.test_custom.instruction).toContain("Test the admin dashboard data export feature");
204
+ expect(nodes.test_custom.instruction).toContain("CSV file downloads successfully");
205
+ });
206
+ });
207
+ describe("all flow types return valid nodes", () => {
208
+ const flows = [
209
+ { type: "registration", path: "/signup" },
210
+ { type: "login", path: "/login" },
211
+ { type: "purchase", path: "/pricing" },
212
+ { type: "onboarding", path: "/onboarding" },
213
+ { type: "upgrade", path: "/upgrade" },
214
+ { type: "cancellation", path: "/cancel" },
215
+ { type: "custom", path: "/test", description: "custom test" },
216
+ ];
217
+ for (const flow of flows) {
218
+ it(`${flow.type}: every node has non-empty name, instruction, and skills array`, () => {
219
+ const { nodes } = buildFlowNodes(flow);
220
+ for (const [id, node] of Object.entries(nodes)) {
221
+ expect(node.name, `${id}.name`).toBeTruthy();
222
+ expect(node.instruction.length, `${id}.instruction`).toBeGreaterThan(0);
223
+ expect(Array.isArray(node.skills), `${id}.skills`).toBe(true);
224
+ }
225
+ });
226
+ }
227
+ });
228
+ describe("auth-dependent flows include login node", () => {
229
+ const authFlows = ["purchase", "onboarding", "upgrade", "cancellation"];
230
+ for (const type of authFlows) {
231
+ it(`${type} includes a login node`, () => {
232
+ const { nodes } = buildFlowNodes({ type, path: "/test" });
233
+ expect(nodes.login).toBeDefined();
234
+ expect(nodes.login.instruction).toContain("{email}");
235
+ });
236
+ }
237
+ });
238
+ });
239
+ describe("buildFlowWorkflow", () => {
240
+ it("returns a valid Workflow object for registration", () => {
241
+ const wf = buildFlowWorkflow({ type: "registration", path: "/signup" }, "http://localhost:3000");
242
+ expect(wf.id).toBe("e2e-registration");
243
+ expect(wf.entry).toBe("setup");
244
+ expect(wf.nodes.setup).toBeDefined();
245
+ expect(wf.nodes.test_registration).toBeDefined();
246
+ expect(wf.nodes.report).toBeDefined();
247
+ });
248
+ it("passes Zod schema validation", () => {
249
+ const wf = buildFlowWorkflow({ type: "registration", path: "/signup" }, "http://localhost:3000");
250
+ expect(() => workflowZ.parse(wf)).not.toThrow();
251
+ });
252
+ it("passes structural validation (no cycles, all reachable)", () => {
253
+ const wf = buildFlowWorkflow({ type: "registration", path: "/signup" }, "http://localhost:3000");
254
+ const errors = validateWorkflow(wf);
255
+ expect(errors).toEqual([]);
256
+ });
257
+ it("includes login node for auth-dependent flows", () => {
258
+ const wf = buildFlowWorkflow({ type: "purchase", path: "/pricing" }, "http://localhost:3000");
259
+ expect(wf.nodes.login).toBeDefined();
260
+ expect(wf.edges.some((e) => e.from === "setup" && e.to === "login")).toBe(true);
261
+ expect(wf.edges.some((e) => e.from === "login" && e.to === "test_purchase")).toBe(true);
262
+ });
263
+ it("adds cleanup node when enabled", () => {
264
+ const wf = buildFlowWorkflow({ type: "registration", path: "/signup" }, "http://localhost:3000", {
265
+ enabled: true,
266
+ backend: "supabase",
267
+ });
268
+ expect(wf.nodes.cleanup).toBeDefined();
269
+ expect(wf.edges.some((e) => e.to === "cleanup")).toBe(true);
270
+ expect(wf.edges.some((e) => e.from === "cleanup" && e.to === "report")).toBe(true);
271
+ });
272
+ it("skips cleanup node when not enabled", () => {
273
+ const wf = buildFlowWorkflow({ type: "registration", path: "/signup" }, "http://localhost:3000", {
274
+ enabled: false,
275
+ });
276
+ expect(wf.nodes.cleanup).toBeUndefined();
277
+ });
278
+ it("all 7 flow types produce valid workflows", () => {
279
+ const types = [
280
+ { type: "registration", path: "/signup" },
281
+ { type: "login", path: "/login" },
282
+ { type: "purchase", path: "/pricing" },
283
+ { type: "onboarding", path: "/onboarding" },
284
+ { type: "upgrade", path: "/upgrade" },
285
+ { type: "cancellation", path: "/cancel" },
286
+ { type: "custom", path: "/test" },
287
+ ];
288
+ for (const { type, path: flowPath } of types) {
289
+ const wf = buildFlowWorkflow({ type, path: flowPath, description: "test custom flow" }, "http://localhost:3000");
290
+ expect(() => workflowZ.parse(wf), `${type}: schema`).not.toThrow();
291
+ const errors = validateWorkflow(wf);
292
+ expect(errors, `${type}: structural`).toEqual([]);
293
+ }
294
+ });
295
+ it("includes failure edge from setup to report", () => {
296
+ const wf = buildFlowWorkflow({ type: "registration", path: "/signup" }, "http://localhost:3000");
297
+ expect(wf.edges.some((e) => e.from === "setup" && e.to === "report" && e.when?.includes("fail"))).toBe(true);
298
+ });
299
+ it("includes failure edges from login to report for auth flows", () => {
300
+ const wf = buildFlowWorkflow({ type: "purchase", path: "/pricing" }, "http://localhost:3000");
301
+ expect(wf.edges.some((e) => e.from === "login" && e.to === "report" && e.when?.includes("fail"))).toBe(true);
302
+ });
303
+ });
304
+ describe("buildE2eEnvTemplate", () => {
305
+ it("always includes E2E_BASE_URL", () => {
306
+ const env = buildE2eEnvTemplate({
307
+ flows: [{ type: "registration", path: "/signup" }],
308
+ baseUrl: "http://localhost:3000",
309
+ cleanup: { enabled: false },
310
+ });
311
+ expect(env).toContain("E2E_BASE_URL=http://localhost:3000");
312
+ });
313
+ it("includes E2E_EMAIL and E2E_PASSWORD for auth-dependent flows", () => {
314
+ const env = buildE2eEnvTemplate({
315
+ flows: [{ type: "purchase", path: "/pricing" }],
316
+ baseUrl: "http://localhost:3000",
317
+ cleanup: { enabled: false },
318
+ });
319
+ expect(env).toContain("E2E_EMAIL=");
320
+ expect(env).toContain("E2E_PASSWORD=");
321
+ });
322
+ it("does not include E2E_EMAIL for registration-only flows", () => {
323
+ const env = buildE2eEnvTemplate({
324
+ flows: [{ type: "registration", path: "/signup" }],
325
+ baseUrl: "http://localhost:3000",
326
+ cleanup: { enabled: false },
327
+ });
328
+ expect(env).not.toContain("E2E_EMAIL=");
329
+ });
330
+ it("includes SUPABASE vars when cleanup backend is supabase", () => {
331
+ const env = buildE2eEnvTemplate({
332
+ flows: [{ type: "registration", path: "/signup" }],
333
+ baseUrl: "http://localhost:3000",
334
+ cleanup: { enabled: true, backend: "supabase" },
335
+ });
336
+ expect(env).toContain("SUPABASE_URL=");
337
+ expect(env).toContain("SUPABASE_SERVICE_ROLE_KEY=");
338
+ });
339
+ it("includes FIREBASE var when cleanup backend is firebase", () => {
340
+ const env = buildE2eEnvTemplate({
341
+ flows: [{ type: "registration", path: "/signup" }],
342
+ baseUrl: "http://localhost:3000",
343
+ cleanup: { enabled: true, backend: "firebase" },
344
+ });
345
+ expect(env).toContain("FIREBASE_SERVICE_ACCOUNT_KEY=");
346
+ });
347
+ it("includes DATABASE_URL when cleanup backend is postgres", () => {
348
+ const env = buildE2eEnvTemplate({
349
+ flows: [{ type: "registration", path: "/signup" }],
350
+ baseUrl: "http://localhost:3000",
351
+ cleanup: { enabled: true, backend: "postgres" },
352
+ });
353
+ expect(env).toContain("DATABASE_URL=");
354
+ });
355
+ });
package/dist/cli/main.js CHANGED
@@ -21,6 +21,7 @@ import { DagRenderer } from "./renderer.js";
21
21
  import * as readline from "node:readline";
22
22
  import { loadDotenv, loadConfigFile } from "./config-file.js";
23
23
  import { runInit } from "./init.js";
24
+ import { runE2eInit, runE2eRun } from "./e2e.js";
24
25
  import { registerTriageCommand, registerImplementCommand, parseCliInputs, validateInputs, validateWarnings, } from "./config.js";
25
26
  import { c, formatBanner, getStepDetails, formatStepLine, formatDagResultHuman, formatResultJson, formatValidationErrors, formatCrashError, formatCheckResults, } from "./output.js";
26
27
  import { checkProviderConnectivity } from "./check.js";
@@ -60,6 +61,24 @@ program
60
61
  .action(async () => {
61
62
  await runInit();
62
63
  });
64
+ // ── sweny e2e ────────────────────────────────────────────────────────
65
+ const e2eCmd = program.command("e2e").description("End-to-end browser testing");
66
+ e2eCmd
67
+ .command("init")
68
+ .description("Interactive wizard — generates .sweny/e2e/*.yml workflow files")
69
+ .action(async () => {
70
+ await runE2eInit();
71
+ });
72
+ e2eCmd
73
+ .command("run [file]")
74
+ .description("Run e2e workflow files from .sweny/e2e/")
75
+ .option("--timeout <ms>", "Timeout per workflow in milliseconds (default: 900000 = 15 min)")
76
+ .action(async (file, options) => {
77
+ await runE2eRun({
78
+ file,
79
+ timeout: options.timeout ? parseInt(options.timeout, 10) : undefined,
80
+ });
81
+ });
63
82
  // ── sweny check ───────────────────────────────────────────────────────
64
83
  program
65
84
  .command("check")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"