@fasttest-ai/qa-agent 0.4.2 → 1.0.0

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.
package/README.md CHANGED
@@ -109,7 +109,7 @@ Higher-level tools that return structured prompts for your coding agent:
109
109
  | ----------------- | ------------------------------------------------------------------- |
110
110
  | `test` | Test a web app (navigates, snapshots, returns testing instructions) |
111
111
  | `explore` | Discover pages, forms, and flows |
112
- | `chaos` | Adversarial testing (XSS, SQL injection, fuzzing) |
112
+ | `chaos` | Adversarial testing for stability and edge cases |
113
113
  | `vibe_shield` | One-command safety net: explore, generate tests, run regressions |
114
114
  | `heal` | Fix broken CSS selectors using multiple strategies |
115
115
  | `setup` | Authenticate with FastTest cloud (device auth flow) |
@@ -157,32 +157,6 @@ Use `{{VAR_NAME}}` placeholders in test steps for secrets (passwords, API keys).
157
157
  --browser <type> chromium | firefox | webkit (default: chromium)
158
158
  ```
159
159
 
160
- ## Architecture
161
-
162
- ```
163
- local-skill/
164
- src/
165
- index.ts MCP server — tool registration and routing
166
- browser.ts Playwright lifecycle, sessions, dialogs
167
- actions.ts Browser action wrappers (navigate, click, fill, etc.)
168
- runner.ts Test execution orchestrator
169
- healer.ts Selector healing cascade
170
- cloud.ts HTTPS client to FastTest cloud API
171
- config.ts Config file management (~/.fasttest/)
172
- variables.ts {{VAR}} placeholder resolution
173
- cli.ts CI runner (no MCP dependency)
174
- ```
175
-
176
- Key design principle: **the MCP server never calls an LLM**. Your coding agent drives all browser interactions using its own reasoning. The server provides tools and structured prompts.
177
-
178
- ## Development
179
-
180
- ```bash
181
- npm install
182
- npm run build # Compile TypeScript
183
- npm run dev # Watch mode
184
- ```
185
-
186
160
  ## License
187
161
 
188
162
  Apache-2.0
package/bin/qa-agent.js CHANGED
@@ -3,6 +3,10 @@
3
3
  const sub = process.argv[2];
4
4
  if (sub === "install" || sub === "uninstall") {
5
5
  await import("../dist/install.js");
6
+ } else if (sub === "run") {
7
+ // Strip the "run" subcommand so cli.ts sees the flags directly
8
+ process.argv.splice(2, 1);
9
+ await import("../dist/cli.js");
6
10
  } else {
7
11
  await import("../dist/index.js");
8
12
  }
@@ -0,0 +1,63 @@
1
+ ---
2
+ description: Test, explore, or break a web app with FastTest
3
+ argument-hint: "[url] description" (e.g. "localhost:3000 login flow")
4
+ ---
5
+
6
+ # FastTest Quick Command
7
+
8
+ Parse the user's input and call the appropriate FastTest MCP tool.
9
+
10
+ ## Input
11
+
12
+ $ARGUMENTS
13
+
14
+ ## If Input is Empty
15
+
16
+ If `$ARGUMENTS` is blank or missing, show this help message and stop (do NOT call any tool):
17
+
18
+ ```
19
+ /ftest — FastTest quick command
20
+
21
+ Usage:
22
+ /ftest [url] [description] Test a web app (default)
23
+ /ftest explore [url] Discover pages, forms, and flows
24
+ /ftest chaos [url] Adversarial testing — try to break it
25
+ /ftest shield [url] One-command regression safety net
26
+
27
+ Examples:
28
+ /ftest localhost:3000 login flow
29
+ /ftest explore localhost:3000
30
+ /ftest chaos localhost:5173 forms
31
+ /ftest shield localhost:3000
32
+ ```
33
+
34
+ Do not call any tool. Just print the help text above and stop.
35
+
36
+ ## Routing Rules
37
+
38
+ Examine the input and determine which mode to use:
39
+
40
+ 1. **If input starts with `explore`** → use the `mcp__fasttest__explore` tool
41
+ 2. **If input starts with `chaos` or `break`** → use the `mcp__fasttest__chaos` tool
42
+ 3. **If input starts with `shield`** → use the `mcp__fasttest__vibe_shield` tool
43
+ 4. **Otherwise** → use the `mcp__fasttest__test` tool (default)
44
+
45
+ ## Parsing the Arguments
46
+
47
+ After removing the mode keyword (if any), the remaining text contains:
48
+
49
+ - **URL** (optional): anything that looks like a URL or `localhost:PORT` pattern. If no protocol, prepend `http://`.
50
+ - **Description** (optional): everything else is the test description.
51
+
52
+ Examples:
53
+ - `/ftest localhost:3000 login flow` → test tool, url=`http://localhost:3000`, description=`login flow`
54
+ - `/ftest explore http://localhost:3000` → explore tool, url=`http://localhost:3000`
55
+ - `/ftest chaos localhost:5173 forms` → chaos tool, url=`http://localhost:5173`, focus=`forms`
56
+ - `/ftest shield localhost:3000` → vibe_shield tool, url=`http://localhost:3000`
57
+ - `/ftest checkout flow` → test tool, description=`checkout flow` (no URL — the tool will handle it)
58
+
59
+ ## Execution
60
+
61
+ Call the resolved MCP tool now with the parsed parameters. Do not ask for confirmation — execute immediately.
62
+
63
+ If `.fasttest.json` exists in the project root, the project name is already cached and the MCP tools will use it automatically — you do not need to pass it.
package/commands/qa.md ADDED
@@ -0,0 +1,63 @@
1
+ ---
2
+ description: Test, explore, or break a web app with FastTest
3
+ argument-hint: "[url] description" (e.g. "localhost:3000 login flow")
4
+ ---
5
+
6
+ # FastTest Quick Command
7
+
8
+ Parse the user's input and call the appropriate FastTest MCP tool.
9
+
10
+ ## Input
11
+
12
+ $ARGUMENTS
13
+
14
+ ## If Input is Empty
15
+
16
+ If `$ARGUMENTS` is blank or missing, show this help message and stop (do NOT call any tool):
17
+
18
+ ```
19
+ /ftest — FastTest quick command
20
+
21
+ Usage:
22
+ /ftest [url] [description] Test a web app (default)
23
+ /ftest explore [url] Discover pages, forms, and flows
24
+ /ftest chaos [url] Adversarial testing — try to break it
25
+ /ftest shield [url] One-command regression safety net
26
+
27
+ Examples:
28
+ /ftest localhost:3000 login flow
29
+ /ftest explore localhost:3000
30
+ /ftest chaos localhost:5173 forms
31
+ /ftest shield localhost:3000
32
+ ```
33
+
34
+ Do not call any tool. Just print the help text above and stop.
35
+
36
+ ## Routing Rules
37
+
38
+ Examine the input and determine which mode to use:
39
+
40
+ 1. **If input starts with `explore`** → use the `mcp__fasttest__explore` tool
41
+ 2. **If input starts with `chaos` or `break`** → use the `mcp__fasttest__chaos` tool
42
+ 3. **If input starts with `shield`** → use the `mcp__fasttest__vibe_shield` tool
43
+ 4. **Otherwise** → use the `mcp__fasttest__test` tool (default)
44
+
45
+ ## Parsing the Arguments
46
+
47
+ After removing the mode keyword (if any), the remaining text contains:
48
+
49
+ - **URL** (optional): anything that looks like a URL or `localhost:PORT` pattern. If no protocol, prepend `http://`.
50
+ - **Description** (optional): everything else is the test description.
51
+
52
+ Examples:
53
+ - `/ftest localhost:3000 login flow` → test tool, url=`http://localhost:3000`, description=`login flow`
54
+ - `/ftest explore http://localhost:3000` → explore tool, url=`http://localhost:3000`
55
+ - `/ftest chaos localhost:5173 forms` → chaos tool, url=`http://localhost:5173`, focus=`forms`
56
+ - `/ftest shield localhost:3000` → vibe_shield tool, url=`http://localhost:3000`
57
+ - `/ftest checkout flow` → test tool, description=`checkout flow` (no URL — the tool will handle it)
58
+
59
+ ## Execution
60
+
61
+ Call the resolved MCP tool now with the parsed parameters. Do not ask for confirmation — execute immediately.
62
+
63
+ If `.fasttest.json` exists in the project root, the project name is already cached and the MCP tools will use it automatically — you do not need to pass it.
package/dist/cli.js CHANGED
@@ -1,195 +1,34 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * FastTest Agent CI Runner headless test execution for CI/CD pipelines.
4
- * No MCP dependency. Calls executeRun() directly.
5
- *
6
- * Usage:
7
- * fasttest-ci --api-key <key> --suite-id <id> [options]
8
- *
9
- * Options:
10
- * --api-key Organization API key (required)
11
- * --suite-id Test suite ID to run (required)
12
- * --base-url Cloud API base URL (default: https://api.fasttest.ai)
13
- * --app-url Override the application URL for test navigation
14
- * --pr-url GitHub PR URL for posting results as a comment
15
- * --browser Browser engine: chromium | firefox | webkit (default: chromium)
16
- * --test-case-ids Comma-separated test case IDs to run (default: all)
17
- * --json Output results as JSON instead of formatted text
18
- */
19
- import { readFileSync } from "node:fs";
20
- import { join, dirname } from "node:path";
21
- import { fileURLToPath } from "node:url";
22
- import { BrowserManager, sanitizePath } from "./browser.js";
23
- import { CloudClient } from "./cloud.js";
24
- import { executeRun } from "./runner.js";
25
- const __dirname = dirname(fileURLToPath(import.meta.url));
26
- const PKG_VERSION = (() => {
27
- try {
28
- const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
29
- return pkg.version ?? "0.0.0";
30
- }
31
- catch {
32
- return "0.0.0";
33
- }
34
- })();
35
- function parseCliArgs() {
36
- const args = process.argv.slice(2);
37
- let apiKey = "";
38
- let suiteId = "";
39
- let baseUrl = "https://api.fasttest.ai";
40
- let appUrl;
41
- let prUrl;
42
- let browserType = "chromium";
43
- let testCaseIds;
44
- let json = false;
45
- for (let i = 0; i < args.length; i++) {
46
- switch (args[i]) {
47
- case "--api-key":
48
- apiKey = args[++i] ?? "";
49
- break;
50
- case "--suite-id":
51
- suiteId = args[++i] ?? "";
52
- break;
53
- case "--base-url":
54
- baseUrl = args[++i] ?? baseUrl;
55
- break;
56
- case "--app-url":
57
- appUrl = args[++i];
58
- break;
59
- case "--pr-url":
60
- prUrl = args[++i];
61
- break;
62
- case "--browser":
63
- browserType = (args[++i] ?? "chromium");
64
- break;
65
- case "--test-case-ids":
66
- testCaseIds = (args[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
67
- break;
68
- case "--json":
69
- json = true;
70
- break;
71
- }
72
- }
73
- if (!apiKey || !suiteId) {
74
- console.error("Usage: fasttest-ci --api-key <key> --suite-id <id> [--base-url <url>] " +
75
- "[--app-url <url>] [--pr-url <url>] [--browser chromium|firefox|webkit] " +
76
- "[--test-case-ids id1,id2] [--json]");
77
- process.exit(1);
78
- }
79
- return { apiKey, suiteId, baseUrl, appUrl, prUrl, browser: browserType, testCaseIds, json };
80
- }
81
- // ---------------------------------------------------------------------------
82
- // Formatted output
83
- // ---------------------------------------------------------------------------
84
- function printFormattedResults(summary) {
85
- const statusLabel = summary.status === "passed" ? "PASSED" : "FAILED";
86
- console.log(`--- Results: ${statusLabel} ---`);
87
- console.log(`Execution: ${summary.execution_id}`);
88
- console.log(`Total: ${summary.total} | Passed: ${summary.passed} | Failed: ${summary.failed} | Skipped: ${summary.skipped}`);
89
- console.log(`Duration: ${(summary.duration_ms / 1000).toFixed(1)}s`);
90
- console.log("");
91
- for (const r of summary.results) {
92
- const icon = r.status === "passed" ? "PASS" : r.status === "failed" ? "FAIL" : "SKIP";
93
- console.log(` [${icon}] ${r.name} (${r.duration_ms}ms)`);
94
- if (r.error) {
95
- console.log(` Error: ${r.error}`);
96
- }
97
- }
98
- if (summary.healed.length > 0) {
99
- console.log("");
100
- console.log(`--- Self-Healed: ${summary.healed.length} selector(s) ---`);
101
- for (const h of summary.healed) {
102
- console.log(` "${h.test_case}" step ${h.step_index + 1}`);
103
- console.log(` ${h.original_selector} -> ${h.new_selector}`);
104
- console.log(` Strategy: ${h.strategy} (${Math.round(h.confidence * 100)}% confidence)`);
105
- }
106
- }
107
- }
108
- // ---------------------------------------------------------------------------
109
- // Main
110
- // ---------------------------------------------------------------------------
111
- async function main() {
112
- const config = parseCliArgs();
113
- const orgSlug = sanitizePath(config.apiKey.split("_")[1] ?? "default");
114
- const browserMgr = new BrowserManager({
115
- browserType: config.browser,
116
- headless: true,
117
- orgSlug,
118
- });
119
- const cloud = new CloudClient({
120
- apiKey: config.apiKey,
121
- baseUrl: config.baseUrl,
122
- });
123
- const consoleLogs = [];
124
- console.log(`FastTest CI Runner v${PKG_VERSION}`);
125
- console.log(`Suite: ${config.suiteId}`);
126
- console.log(`Browser: ${config.browser}`);
127
- if (config.appUrl)
128
- console.log(`App URL: ${config.appUrl}`);
129
- console.log("");
130
- let summary;
131
- try {
132
- summary = await executeRun(browserMgr, cloud, {
133
- suiteId: config.suiteId,
134
- testCaseIds: config.testCaseIds,
135
- appUrlOverride: config.appUrl,
136
- }, consoleLogs);
137
- }
138
- catch (err) {
139
- console.error(`Fatal: ${err}`);
140
- await safeClose(browserMgr);
141
- process.exit(1);
142
- }
143
- // Output results
144
- if (config.json) {
145
- console.log(JSON.stringify(summary, null, 2));
146
- }
147
- else {
148
- printFormattedResults(summary);
149
- }
150
- // Post PR comment if requested
151
- if (config.prUrl) {
152
- try {
153
- const prResult = await cloud.postPrComment({
154
- pr_url: config.prUrl,
155
- execution_id: summary.execution_id,
156
- status: summary.status,
157
- total: summary.total,
158
- passed: summary.passed,
159
- failed: summary.failed,
160
- skipped: summary.skipped,
161
- duration_seconds: Math.round(summary.duration_ms / 1000),
162
- test_results: summary.results.map((r) => ({
163
- name: r.name,
164
- status: r.status,
165
- error: r.error,
166
- })),
167
- healed: summary.healed.map((h) => ({
168
- original_selector: h.original_selector,
169
- new_selector: h.new_selector,
170
- strategy: h.strategy,
171
- confidence: h.confidence,
172
- })),
173
- });
174
- const commentUrl = prResult.comment_url;
175
- console.log(`\nPR comment posted: ${commentUrl ?? config.prUrl}`);
176
- }
177
- catch (err) {
178
- console.error(`\nFailed to post PR comment: ${err}`);
179
- }
180
- }
181
- await safeClose(browserMgr);
182
- process.exit(summary.status === "passed" ? 0 : 1);
183
- }
184
- /** Close browser with a timeout so CI doesn't hang. */
185
- async function safeClose(browserMgr) {
186
- await Promise.race([
187
- browserMgr.close(),
188
- new Promise((resolve) => setTimeout(resolve, 5000)),
189
- ]);
190
- }
191
- main().catch((err) => {
192
- console.error("Fatal:", err);
193
- process.exit(1);
194
- });
195
- //# sourceMappingURL=cli.js.map
2
+ import{readFileSync as We}from"node:fs";import{join as ze,dirname as Ge}from"node:path";import{fileURLToPath as Ze}from"node:url";import{chromium as ke,firefox as Re,webkit as Te,devices as Ce}from"playwright";import{execFileSync as Ae}from"node:child_process";import*as R from"node:fs";import*as D from"node:path";import*as J from"node:os";var j=D.join(J.homedir(),".fasttest","sessions"),Ee=/^(con|prn|aux|nul|com\d|lpt\d)$/i;function U(s){let t=s.replace(/[\/\\]/g,"_").replace(/\.\./g,"_").replace(/\0/g,"").replace(/^_+|_+$/g,"").replace(/^\./,"_")||"default";return Ee.test(t)?`_${t}`:t}var L=class s{browser=null;context=null;page=null;browserType;headless;orgSlug;deviceName;pendingDialogs=new WeakMap;networkEntries=[];constructor(e={}){this.browserType=e.browserType??"chromium",this.headless=e.headless??!0,this.orgSlug=U(e.orgSlug??"default"),this.deviceName=e.device}async setDevice(e){this.deviceName=e,this.page&&!this.page.isClosed()&&await this.page.close().catch(()=>{}),this.context&&await this.context.close().catch(()=>{}),this.page=null,this.context=null}getContextOptions(e){if(this.deviceName){let t=Ce[this.deviceName];if(!t)throw new Error(`Unknown Playwright device "${this.deviceName}". Use a name from Playwright's device registry (e.g. "iPhone 15", "Pixel 7").`);return{...t,ignoreHTTPSErrors:!0,...e}}return{viewport:{width:1280,height:720},ignoreHTTPSErrors:!0,...e}}async ensureBrowser(){if(this.page&&!this.page.isClosed())try{return await this.page.evaluate("1"),this.page}catch{}if(!this.browser||!this.browser.isConnected()){this.context=null,this.page=null;let e=this.browserType==="firefox"?Re:this.browserType==="webkit"?Te:ke;try{this.browser=await e.launch({headless:this.headless,args:this.browserType==="chromium"?["--disable-blink-features=AutomationControlled"]:[]})}catch(t){let r=t instanceof Error?t.message:String(t);if(r.includes("Executable doesn't exist")||r.includes("browserType.launch")){let n=process.platform==="win32"?"npx.cmd":"npx";Ae(n,["playwright","install","--with-deps",this.browserType],{stdio:"inherit"}),this.browser=await e.launch({headless:this.headless,args:this.browserType==="chromium"?["--disable-blink-features=AutomationControlled"]:[]})}else throw t}}return this.context||(this.context=await this.browser.newContext(this.getContextOptions())),this.page=await this.context.newPage(),this.attachDialogListener(this.page),this.attachNetworkListener(this.page),this.page}async getPage(){return this.ensureBrowser()}async newContext(){return(!this.browser||!this.browser.isConnected())&&await this.ensureBrowser(),this.page&&!this.page.isClosed()&&await this.page.close().catch(()=>{}),this.context&&await this.context.close().catch(()=>{}),this.context=await this.browser.newContext(this.getContextOptions()),this.page=await this.context.newPage(),this.attachDialogListener(this.page),this.attachNetworkListener(this.page),this.page}async saveSession(e){if(!this.context)throw new Error("No browser context \u2014 nothing to save");let t=U(e),r=D.join(j,this.orgSlug);R.mkdirSync(r,{recursive:!0,mode:448});let n=D.join(r,`${t}.json`),i=await this.context.storageState();return R.writeFileSync(n,JSON.stringify(i,null,2),{mode:384}),n}async restoreSession(e){let t=U(e),r=D.join(j,this.orgSlug,`${t}.json`);if(!R.existsSync(r))throw new Error(`Session "${e}" not found at ${r}`);let n=JSON.parse(R.readFileSync(r,"utf-8"));return(!this.browser||!this.browser.isConnected())&&await this.ensureBrowser(),this.page&&!this.page.isClosed()&&await this.page.close().catch(()=>{}),this.context&&await this.context.close().catch(()=>{}),this.context=await this.browser.newContext(this.getContextOptions({storageState:n})),this.page=await this.context.newPage(),this.attachDialogListener(this.page),this.attachNetworkListener(this.page),this.page}sessionExists(e){let t=U(e);return R.existsSync(D.join(j,this.orgSlug,`${t}.json`))}listSessions(){let e=D.join(j,this.orgSlug);return R.existsSync(e)?R.readdirSync(e).filter(t=>t.endsWith(".json")).map(t=>t.replace(/\.json$/,"")):[]}attachDialogListener(e){e.on("dialog",t=>{let r=this.pendingDialogs.get(e);r&&clearTimeout(r.dismissTimer);let n=setTimeout(()=>{this.pendingDialogs.get(e)?.dialog===t&&(t.dismiss().catch(()=>{}),this.pendingDialogs.delete(e))},3e4);this.pendingDialogs.set(e,{type:t.type(),message:t.message(),defaultValue:t.defaultValue(),dialog:t,dismissTimer:n})})}async handleDialog(e,t){let r=this.page,n=r?this.pendingDialogs.get(r):void 0;if(!n)throw new Error("No pending dialog to handle");return clearTimeout(n.dismissTimer),this.pendingDialogs.delete(r),e==="accept"?await n.dialog.accept(t):await n.dialog.dismiss(),{type:n.type,message:n.message}}static MAX_NETWORK_ENTRIES=1e3;attachNetworkListener(e){e.on("response",t=>{let r=t.request(),n=r.url();n.startsWith("http")&&(this.networkEntries.length>=s.MAX_NETWORK_ENTRIES&&this.networkEntries.shift(),this.networkEntries.push({url:n,method:r.method(),status:t.status(),duration:0,mimeType:t.headers()["content-type"]??"",responseSize:parseInt(t.headers()["content-length"]??"0",10)}))})}getNetworkSummary(){return[...this.networkEntries]}clearNetworkEntries(){this.networkEntries=[]}listPages(){return this.context?this.context.pages().map((e,t)=>({index:t,url:e.url(),title:""})):[]}async listPagesAsync(){if(!this.context)return[];let e=this.context.pages(),t=[];for(let r=0;r<e.length;r++)t.push({index:r,url:e[r].url(),title:await e[r].title().catch(()=>"")});return t}async createPage(e){this.context||await this.ensureBrowser();let t=await this.context.newPage();return this.attachDialogListener(t),this.attachNetworkListener(t),e&&await t.goto(e,{waitUntil:"domcontentloaded",timeout:3e4}),this.page=t,t}async switchToPage(e){if(!this.context)throw new Error("No browser context \u2014 no tabs to switch to");let t=this.context.pages();if(e<0||e>=t.length)throw new Error(`Tab index ${e} out of range (0-${t.length-1})`);return this.page=t[e],await this.page.bringToFront(),this.page}async closePage(e){if(!this.context)throw new Error("No browser context \u2014 no tabs to close");let t=this.context.pages();if(e<0||e>=t.length)throw new Error(`Tab index ${e} out of range (0-${t.length-1})`);await t[e].close();let n=this.context.pages();n.length>0?this.page=n[Math.min(e,n.length-1)]:this.page=null}async close(){this.page&&!this.page.isClosed()&&await this.page.close().catch(()=>{}),this.context&&await this.context.close().catch(()=>{}),this.browser&&await this.browser.close().catch(()=>{}),this.page=null,this.context=null,this.browser=null}};var K=class extends Error{constructor(t,r,n){super(`Monthly run limit reached (${r}/${n}). Current plan: ${t}. Upgrade at https://fasttest.ai to continue.`);this.plan=t;this.used=r;this.limit=n;this.name="QuotaExceededError"}},B=class{apiKey;baseUrl;constructor(e){this.apiKey=e.apiKey,this.baseUrl=(e.baseUrl??"https://api.fasttest.ai").replace(/\/$/,"")}get dashboardUrl(){try{let e=new URL(this.baseUrl);return e.hostname=e.hostname.replace(/^api\./,""),e.pathname="/",e.origin}catch{return"https://fasttest.ai"}}static async requestDeviceCode(e){let t=`${e.replace(/\/$/,"")}/api/v1/auth/device-code`,r=await fetch(t,{method:"POST"});if(!r.ok){let n=await r.text();throw new Error(`Device code request failed (${r.status}): ${n}`)}return await r.json()}static async fetchPrompts(e){let t=`${e.replace(/\/$/,"")}/api/v1/qa/prompts`,r=await fetch(t,{signal:AbortSignal.timeout(5e3)});if(!r.ok)throw new Error(`Prompt fetch failed (${r.status})`);return await r.json()}static async pollDeviceCode(e,t){let r=`${e.replace(/\/$/,"")}/api/v1/auth/device-code/status?poll_token=${encodeURIComponent(t)}`,n=await fetch(r);if(!n.ok){let i=await n.text();throw new Error(`Device code poll failed (${n.status}): ${i}`)}return await n.json()}async request(e,t,r){let n=`${this.baseUrl}/api/v1${t}`,i={"x-api-key":this.apiKey,"Content-Type":"application/json"},a=2,u=1e3;for(let c=0;c<=a;c++){let d=new AbortController,w=setTimeout(()=>d.abort(),3e4);try{let l={method:e,headers:i,signal:d.signal};r!==void 0&&(l.body=JSON.stringify(r));let m=await fetch(n,l);if(clearTimeout(w),!m.ok){let h=await m.text();if(m.status>=500&&c<a){await new Promise(p=>setTimeout(p,u*2**c));continue}if(m.status===402){let p=h.match(/\((\d+)\/(\d+)\).*plan:\s*(\w+)/i);throw new K(p?.[3]??"unknown",p?parseInt(p[1]):0,p?parseInt(p[2]):0)}throw new Error(`Cloud API ${e} ${t} \u2192 ${m.status}: ${h}`)}return await m.json()}catch(l){if(clearTimeout(w),l instanceof Error&&(l.name==="AbortError"||l.message.includes("fetch failed"))&&c<a){await new Promise(h=>setTimeout(h,u*2**c));continue}throw l}}throw new Error(`Cloud API ${e} ${t}: max retries exceeded`)}async get(e){return this.request("GET",e)}async post(e,t){return this.request("POST",e,t)}async health(){let e=`${this.baseUrl}/health`;return await(await fetch(e)).json()}async listProjects(){return this.get("/qa/projects/")}async resolveProject(e,t){let r={name:e};return t&&(r.base_url=t),this.post("/qa/projects/resolve",r)}async listSuites(e){let t=e?`?search=${encodeURIComponent(e)}`:"";return this.get(`/qa/projects/suites/all${t}`)}async resolveSuite(e,t){let r={name:e};return t&&(r.project_id=t),this.post("/qa/projects/suites/resolve",r)}async createSuite(e,t){return this.post(`/qa/projects/${e}/test-suites`,{...t,project_id:e})}async updateSuite(e,t){return this.request("PUT",`/qa/execution/suites/${e}`,t)}async createTestCase(e){return this.post("/qa/test-cases/",e)}async updateTestCase(e,t){return this.request("PUT",`/qa/test-cases/${e}`,t)}async applyHealing(e,t,r){return this.post(`/qa/test-cases/${e}/apply-healing`,{original_selector:t,healed_selector:r})}async detectSharedSteps(e,t){let r=new URLSearchParams;e&&r.set("project_id",e),t&&r.set("auto_create","true");let n=r.toString()?`?${r.toString()}`:"";return this.post(`/qa/shared-steps/detect${n}`,{})}async resolveEnvironment(e,t){return this.post("/qa/environments/resolve",{suite_id:e,name:t})}async startRun(e){return this.post("/qa/execution/run",e)}async reportResult(e,t){return this.post(`/qa/execution/executions/${e}/results`,t)}async completeExecution(e,t){return this.post(`/qa/execution/executions/${e}/complete`,{status:t})}async cancelExecution(e){return this.post(`/qa/execution/executions/${e}/cancel`,{})}async getExecutionStatus(e){return this.get(`/qa/execution/executions/${e}`)}async getExecutionDiff(e){return this.get(`/qa/execution/executions/${e}/diff`)}async notifyTestStarted(e,t,r){try{await this.post(`/qa/execution/executions/${e}/test-started`,{test_case_id:t,test_case_name:r})}catch{}}async notifyHealingStarted(e,t,r){try{await this.post(`/qa/execution/executions/${e}/healing-started`,{test_case_id:t,original_selector:r})}catch{}}async checkControlStatus(e){return(await this.get(`/qa/execution/executions/${e}/control-status`)).status}async setGithubToken(e){return this.request("PUT","/qa/github/token",{github_token:e})}async postPrComment(e){return this.post("/qa/github/pr-comment",e)}async createLiveSession(e){return this.post("/qa/live-sessions",e)}async updateLiveSession(e,t){return this.request("PATCH",`/qa/live-sessions/${e}`,t)}async saveChaosReport(e,t){let r=e?`?project_id=${e}`:"";return this.post(`/qa/chaos/reports${r}`,t)}};async function X(s,e){try{return await s.goto(e,{waitUntil:"domcontentloaded",timeout:3e4}),await s.waitForLoadState("networkidle",{timeout:5e3}).catch(()=>{}),{success:!0,data:{title:await s.title(),url:s.url()}}}catch(t){return{success:!1,error:String(t)}}}async function Q(s,e){try{return await s.click(e,{timeout:1e4}),await s.waitForLoadState("networkidle",{timeout:1e4}).catch(()=>{}),{success:!0}}catch(t){return{success:!1,error:String(t)}}}async function Y(s,e,t){try{return await s.fill(e,t,{timeout:1e4}),{success:!0}}catch(r){return{success:!1,error:String(r)}}}async function ee(s,e){try{return await s.hover(e,{timeout:1e4}),{success:!0}}catch(t){return{success:!1,error:String(t)}}}async function te(s,e,t){try{return await s.selectOption(e,t,{timeout:1e4}),{success:!0}}catch(r){return{success:!1,error:String(r)}}}async function se(s,e,t=1e4){try{return await s.waitForSelector(e,{timeout:t}),{success:!0}}catch(r){return{success:!1,error:String(r)}}}async function re(s,e=!1){return(await s.screenshot({type:"jpeg",quality:80,fullPage:e})).toString("base64")}async function ne(s){let e=await s.locator("body").ariaSnapshot().catch(()=>"");return{url:s.url(),title:await s.title(),accessibilityTree:e}}async function ie(s){try{return await s.goBack({waitUntil:"domcontentloaded",timeout:3e4})===null?{success:!1,error:"No previous page in history"}:{success:!0,data:{title:await s.title(),url:s.url()}}}catch(e){return{success:!1,error:String(e)}}}async function ae(s){try{return await s.goForward({waitUntil:"domcontentloaded",timeout:3e4})===null?{success:!1,error:"No next page in history"}:{success:!0,data:{title:await s.title(),url:s.url()}}}catch(e){return{success:!1,error:String(e)}}}async function oe(s,e){try{return await s.keyboard.press(e),{success:!0}}catch(t){return{success:!1,error:String(t)}}}async function ce(s,e,t){try{return await s.setInputFiles(e,t,{timeout:1e4}),{success:!0}}catch(r){return{success:!1,error:String(r)}}}async function ue(s,e){try{return{success:!0,data:{result:await s.evaluate(e)}}}catch(t){return{success:!1,error:String(t)}}}async function le(s,e,t){try{return await s.dragAndDrop(e,t,{timeout:1e4}),await s.waitForLoadState("networkidle",{timeout:5e3}).catch(()=>{}),{success:!0}}catch(r){return{success:!1,error:String(r)}}}async function de(s,e,t){try{return await s.setViewportSize({width:e,height:t}),{success:!0,data:{width:e,height:t}}}catch(r){return{success:!1,error:String(r)}}}async function ge(s,e){try{for(let[t,r]of Object.entries(e))await s.fill(t,r,{timeout:1e4});return{success:!0,data:{filled:Object.keys(e).length}}}catch(t){return{success:!1,error:String(t)}}}async function pe(s,e){try{switch(e.type){case"element_visible":{let t=await s.isVisible(e.selector,{timeout:5e3});return{pass:t,actual:t}}case"element_hidden":try{return await s.waitForSelector(e.selector,{state:"hidden",timeout:5e3}),{pass:!0,actual:!0}}catch{return{pass:!1,actual:!1,error:"Element is still visible"}}case"text_contains":{let t=s.locator(e.selector);if(await t.count()===0)return{pass:!1,error:"Element not found"};let n=await t.first().textContent();return{pass:n?.includes(e.text??"")??!1,actual:n??""}}case"text_equals":{let t=s.locator(e.selector);if(await t.count()===0)return{pass:!1,error:"Element not found"};let n=(await t.first().textContent())?.trim()??"";return{pass:n===e.text,actual:n}}case"url_contains":{let t=s.url(),r=e.url??e.text??"";return{pass:t.includes(r),actual:t}}case"url_equals":{let t=s.url();return{pass:t===e.url,actual:t}}case"element_count":{let r=await s.locator(e.selector).count();return{pass:r===(e.count??1),actual:r}}case"attribute_value":{let t=s.locator(e.selector);if(await t.count()===0)return{pass:!1,error:"Element not found"};let n=await t.first().getAttribute(e.attribute??"");return{pass:n===e.value,actual:n??""}}default:return{pass:!1,error:`Unknown assertion type: ${e.type}`}}}catch(t){return{pass:!1,error:String(t)}}}var fe={data_testid:.98,aria:.95,text:.9,structural:.85,ai:.75};async function he(s,e,t,r,n,i,a){if(e)try{let c=await e.post("/qa/healing/classify",{failure_type:r,selector:t,page_url:i,error_message:n});if(c.is_real_bug)return{healed:!1,error:c.reason??"Classified as real bug"};if(c.pattern){let d=await O(s,c.pattern.healed_value),w=d&&await me(s,c.pattern.healed_value,a);if(d&&w)return{healed:!0,newSelector:c.pattern.healed_value,strategy:c.pattern.strategy,confidence:c.pattern.confidence};c.pattern.id&&Fe(e,c.pattern.id,i)}}catch{}let u=[{name:"data_testid",fn:()=>De(s,t)},{name:"aria",fn:()=>Ie(s,t)},{name:"text",fn:()=>Ue(s,t)},{name:"structural",fn:()=>Oe(s,t)}];for(let c of u){let d=await c.fn();if(d){if(!await me(s,d,a))continue;return e&&await qe(e,r,t,d,c.name,fe[c.name]??.8,i),{healed:!0,newSelector:d,strategy:c.name,confidence:fe[c.name]}}}return{healed:!1,error:"Local healing strategies exhausted"}}async function O(s,e){try{return await s.locator(e).count()===1}catch{return!1}}async function me(s,e,t){if(!t)return!0;try{let r=await s.locator(e).evaluate(a=>({tag:a.tagName.toLowerCase(),role:a.getAttribute("role"),type:a.type??null,contentEditable:a.getAttribute("contenteditable"),text:(a.textContent??"").trim().slice(0,200),ariaLabel:a.getAttribute("aria-label")??""})),n=t.action;if(n==="click"||n==="hover"){let a=["button","a","input","select","summary","details","label","option"],u=["button","link","tab","menuitem","checkbox","radio","switch","option"];if(!(a.includes(r.tag)||r.role!=null&&u.includes(r.role)))return!1}if((n==="fill"||n==="type")&&!(r.tag==="input"||r.tag==="textarea"||r.contentEditable==="true"||r.contentEditable==="")||n==="select"&&r.tag!=="select"&&r.role!=="listbox"&&r.role!=="combobox")return!1;let i=[t.description,t.intent].filter(Boolean);for(let a of i){let u=a.match(/['"]([^'"]+)['"]/);if(u){let c=u[1].toLowerCase();if(!(r.text+" "+r.ariaLabel).toLowerCase().includes(c))return!1}}return!0}catch{return!0}}async function De(s,e){try{let t=H(e);if(!t)return null;let r=[`[data-testid="${t}"]`,`[data-test="${t}"]`,`[data-test-id="${t}"]`];for(let n of r)if(await O(s,n))return n;return null}catch{return null}}async function Ie(s,e){try{let t=H(e);if(!t)return null;let r=[`[aria-label="${t}"]`];for(let n of r)if(await O(s,n))return n;return null}catch{return null}}async function Ue(s,e){try{let t=H(e);if(!t)return null;let r=[`[aria-label="${t}"]`,`[title="${t}"]`,`[alt="${t}"]`,`[placeholder="${t}"]`,`role=button[name="${t}"]`,`role=link[name="${t}"]`];for(let n of r)if(await O(s,n))return n;return null}catch{return null}}async function Oe(s,e){try{let r=e.match(/^([a-z]+)/i)?.[1]??"",n=H(e);if(!r&&!n)return null;let i=[];r&&n&&(i.push(`${r}[name="${n}"]`),i.push(`${r}[id*="${n}"]`),i.push(`${r}[class*="${n}"]`));for(let a of i)if(await O(s,a))return a;return null}catch{return null}}function H(s){let e=s.match(/\[(?:data-testid|data-test|data-test-id|id|name|aria-label)\s*[~|^$*]?=\s*["']([^"']+)["']\]/);if(e)return e[1];let t=s.match(/#([\w-]+)/);if(t)return t[1];let r=[...s.matchAll(/\.([\w-]+)/g)];if(r.length>0)return r[r.length-1][1];let n=s.match(/\[name=["']([^"']+)["']\]/);return n?n[1]:s.match(/[a-zA-Z][\w-]{2,}/)?.[0]??null}async function qe(s,e,t,r,n,i,a){try{await s.post("/qa/healing/patterns",{failure_type:e,original_value:t,healed_value:r,strategy:n,confidence:i,page_url:a})}catch{}}async function Fe(s,e,t){try{await s.post(`/qa/healing/patterns/${e}/failed`,{page_url:t})}catch{}}var je=/\{\{([A-Z][A-Z0-9_]*)\}\}/g;function k(s,e=process.env){let t=[],r=s.replace(je,(n,i)=>{let a=e[i];return a===void 0?(t.push(i),n):a});if(t.length>0)throw new Error(`Missing environment variable(s): ${t.join(", ")}. Set these before running tests. In GitHub Actions, add them as repository secrets.`);return r}function V(s,e){let t={...s};if(t.value!==void 0&&(t.value=k(t.value,e)),t.url!==void 0&&(t.url=k(t.url,e)),t.expression!==void 0&&(t.expression=k(t.expression,e)),t.key!==void 0&&(t.key=k(t.key,e)),t.name!==void 0&&(t.name=k(t.name,e)),t.fields!==void 0){let r={};for(let[n,i]of Object.entries(t.fields))r[n]=k(i,e);t.fields=r}return t}function we(s,e){let t={...s};return t.text!==void 0&&(t.text=k(t.text,e)),t.url!==void 0&&(t.url=k(t.url,e)),t.value!==void 0&&(t.value=k(t.value,e)),t.expected_value!==void 0&&(t.expected_value=k(t.expected_value,e)),t}function W(s,e){let t=new Set;function r(n){if(!n)return;let i=/\{\{([A-Z][A-Z0-9_]*)\}\}/g,a;for(;(a=i.exec(n))!==null;)t.add(a[1])}for(let n of s)if(r(n.value),r(n.url),r(n.expression),r(n.key),r(n.name),n.fields)for(let i of Object.values(n.fields))r(i);for(let n of e)r(n.text),r(n.url),r(n.value),r(n.expected_value);return Array.from(t).sort()}async function ye(s,e,t,r){await s.setDevice(t.device);let n=await e.startRun({suite_id:t.suiteId,environment_id:t.environmentId,browser:"chromium",test_case_ids:t.testCaseIds,device:t.device}),i=n.execution_id,a=n.test_cases,u=n.default_session??void 0,c=t.appUrlOverride??n.base_url??"";if(c)try{c=k(c)}catch(o){try{await e.completeExecution(i)}catch{}return{execution_id:i,status:"failed",total:a.length,passed:0,failed:a.length,skipped:0,duration_ms:0,results:a.map(g=>({id:g.id,name:g.name,status:"failed",duration_ms:0,error:String(o),step_results:[]})),healed:[]}}let d=[];for(let o of a)for(let g of W(o.steps,o.assertions))d.includes(g)||d.push(g);if(n.setup){let o=Array.isArray(n.setup)?n.setup:Object.values(n.setup).flat();for(let g of W(o,[]))d.includes(g)||d.push(g)}let w=[u,...a.map(o=>o.session).filter(Boolean)].filter(Boolean);for(let o of w){let g=o.matchAll(/\{\{([A-Z][A-Z0-9_]*)\}\}/g);for(let f of g)d.includes(f[1])||d.push(f[1])}if(d.length>0){let o=[],g=[];for(let f of d)process.env[f]!==void 0?o.push(f):g.push(f);if(o.length>0&&process.stderr.write(`Environment variables resolved: ${o.join(", ")}
3
+ `),g.length>0){let f=`Missing environment variable(s): ${g.join(", ")}. Set these before running tests. In GitHub Actions, add them as repository secrets.`;process.stderr.write(`ERROR: ${f}
4
+ `);try{await e.completeExecution(i)}catch{}return{execution_id:i,status:"failed",total:a.length,passed:0,failed:a.length,skipped:0,duration_ms:0,results:a.map(P=>({id:P.id,name:P.name,status:"failed",duration_ms:0,error:f,step_results:[]})),healed:[]}}}let l=n.setup;if(l){let o;Array.isArray(l)?u?o={[u]:l}:(process.stderr.write(`Warning: suite has setup steps but no default_session set. Setup will be skipped. Set the suite's session field to enable CI login.
5
+ `),o={}):o=l;for(let[g,f]of Object.entries(o)){if(s.sessionExists(g)){process.stderr.write(`Session "${g}" found locally \u2014 skipping setup.
6
+ `);continue}if(f.length===0)continue;process.stderr.write(`Session "${g}" not found \u2014 running setup (${f.length} steps)...
7
+ `);let P=await s.newContext(),E=!1;for(let C=0;C<f.length;C++){let _;try{_=V(f[C])}catch(A){let T=`Setup "${g}" step ${C+1} failed to resolve variables: ${A}`;process.stderr.write(`ERROR: ${T}
8
+ `),E=!0;try{await e.completeExecution(i)}catch{}return{execution_id:i,status:"failed",total:a.length,passed:0,failed:a.length,skipped:0,duration_ms:0,results:a.map(N=>({id:N.id,name:N.name,status:"failed",duration_ms:0,error:T,step_results:[]})),healed:[]}}let $=await z(P,_,c,s);if($.page&&(P=$.page),!$.success){let A=`Setup "${g}" step ${C+1} (${_.action}) failed: ${$.error}`;process.stderr.write(`ERROR: ${A}
9
+ `),E=!0;try{await e.completeExecution(i)}catch{}return{execution_id:i,status:"failed",total:a.length,passed:0,failed:a.length,skipped:0,duration_ms:0,results:a.map(T=>({id:T.id,name:T.name,status:"failed",duration_ms:0,error:A,step_results:[]})),healed:[]}}}E||(await s.saveSession(g),process.stderr.write(`Setup complete \u2014 session "${g}" saved.
10
+ `))}}else u&&!s.sessionExists(u)&&process.stderr.write(`Warning: session "${u}" not found and no setup steps defined. Tests will run without auth. Add setup steps to the suite for CI support.
11
+ `);let m=Ve(a);n.previous_statuses&&(m=Ke(m,n.previous_statuses));let h=[],p=[],b=Date.now(),S=!1,x=0,y=new Set,I=new Set(m.map(o=>o.id));for(let o of m){if(o.depends_on&&o.depends_on.length>0){let _=o.depends_on.filter($=>I.has($)&&!y.has($));if(_.length>0){h.push({id:o.id,name:o.name,status:"skipped",duration_ms:0,error:`Skipped: dependency not met (${_.join(", ")})`,step_results:[]});continue}}try{let _=await e.checkControlStatus(i);if(_==="cancelled"){S=!0;break}if(_==="paused"){let $=!1,A=Date.now(),T=30*60*1e3;for(;!$;){if(Date.now()-A>T){process.stderr.write(`Pause exceeded 30-minute limit, auto-cancelling.
12
+ `),S=!0;break}await new Promise($e=>setTimeout($e,2e3));let N=await e.checkControlStatus(i);if(N==="running"&&($=!0),N==="cancelled"){S=!0;break}}if(S)break}}catch{}let g=o.retry_count??0,f,P=0;for(await e.notifyTestStarted(i,o.id,o.name);;){let _=(o.timeout_seconds||30)*1e3,$,A=new Promise((T,N)=>{$=setTimeout(()=>N(new Error(`Test case "${o.name}" timed out after ${o.timeout_seconds||30}s`)),_)});if(f=await Promise.race([Le(s,e,i,o,c,r,p,t.aiFallback,u),A]).finally(()=>clearTimeout($)).catch(T=>({id:o.id,name:o.name,status:"failed",duration_ms:_,error:String(T),step_results:[]})),f.status==="passed"||P>=g)break;P++,process.stderr.write(`Retrying ${o.name} (attempt ${P}/${g})...
13
+ `)}f.retry_attempts=P,f.status==="passed"&&y.add(o.id),h.push(f);let E=s.getNetworkSummary();s.clearNetworkEntries();let C=Me(E);try{await e.reportResult(i,{test_case_id:o.id,status:f.status,duration_ms:f.duration_ms,error_message:f.error,console_logs:r.slice(-50),retry_attempt:P,step_results:f.step_results.map(_=>({step_index:_.step_index,action:_.action,success:_.success,error:_.error,duration_ms:_.duration_ms,screenshot_url:_.screenshot_url,healed:_.healed,heal_details:_.heal_details})),network_summary:C.length>0?C:void 0})}catch(_){x++,process.stderr.write(`Failed to report result for ${o.name}: ${_}
14
+ `)}}let q=new Set(h.map(o=>o.id));for(let o of a)q.has(o.id)||h.push({id:o.id,name:o.name,status:"skipped",duration_ms:0,step_results:[]});let v=.9;if(p.length>0){let o=new Set;for(let g of p){if(g.confidence<v)continue;let f=`${g.test_case_id}:${g.original_selector}`;if(!o.has(f)){o.add(f);try{await e.applyHealing(g.test_case_id,g.original_selector,g.new_selector),process.stderr.write(`Auto-updated selector in "${g.test_case}": ${g.original_selector} \u2192 ${g.new_selector}
15
+ `)}catch{}}}}let M=h.filter(o=>o.status==="passed").length,F=h.filter(o=>o.status==="failed").length,Se=h.filter(o=>o.status==="skipped").length,Pe=Date.now()-b;try{await e.completeExecution(i,S?"cancelled":void 0)}catch(o){process.stderr.write(`Failed to complete execution: ${o}
16
+ `)}x>0&&process.stderr.write(`Warning: ${x} result report(s) failed to send to cloud.
17
+ `);let Z;if(t.aiFallback)for(let o of h){if(o.status!=="failed")continue;let g=o.step_results.find(f=>!f.success&&f.ai_context);if(g?.ai_context){let P=m.find(E=>E.id===o.id)?.steps[g.step_index]??{};Z={test_case_id:o.id,test_case_name:o.name,step_index:g.step_index,step:P,intent:g.ai_context.intent,error:g.error??o.error??"Unknown error",page_url:g.ai_context.page_url,snapshot:g.ai_context.snapshot};break}}return{execution_id:i,status:S?"cancelled":F===0?"passed":"failed",total:a.length,passed:M,failed:F,skipped:Se,duration_ms:Pe,results:h,healed:p,ai_fallback:Z}}async function Le(s,e,t,r,n,i,a,u,c){let d=[],w=Date.now();try{let l=r.session??c,m;if(l)try{m=k(l)}catch(p){if(/\{\{[A-Z_]+\}\}/.test(l))return{id:r.id,name:r.name,status:"failed",duration_ms:Date.now()-w,error:`Session name "${l}" contains unresolved variable: ${p}`,step_results:[]};m=l}let h;if(m)try{h=await s.restoreSession(m)}catch(p){process.stderr.write(`Warning: session "${m}" not found, using fresh context: ${p}
18
+ `),h=await s.newContext()}else h=await s.newContext();for(let p=0;p<r.steps.length;p++){let b=r.steps[p],S=Date.now(),x;try{x=V(b)}catch(v){return d.push({step_index:p,action:b.action,success:!1,error:String(v),duration_ms:Date.now()-S}),{id:r.id,name:r.name,status:"failed",duration_ms:Date.now()-w,error:`Step ${p+1} (${b.action}) failed: ${String(v)}`,step_results:d}}let y=await z(h,x,n,s);if(y.page&&(h=y.page),!y.success&&x.selector&&Be(y.error)){await e.notifyHealingStarted(t,r.id,x.selector);let v=await he(h,e,x.selector,He(y.error),y.error??"unknown",h.url(),{action:x.action,description:x.description,intent:x.intent});if(v.healed&&v.newSelector){let M={...x,selector:v.newSelector};if(y=await z(h,M,n,s),y.success){a.push({test_case_id:r.id,test_case:r.name,step_index:p,original_selector:b.selector,new_selector:v.newSelector,strategy:v.strategy??"unknown",confidence:v.confidence??0});let F=await _e(h);d.push({step_index:p,action:b.action,success:!0,duration_ms:Date.now()-S,screenshot_url:F?.dataUrl,healed:!0,heal_details:{original_selector:b.selector,new_selector:v.newSelector,strategy:v.strategy??"unknown",confidence:v.confidence??0}});continue}}}let I=await _e(h),q;if(!y.success&&u)try{let v=await ne(h);q={intent:x.intent??x.description,page_url:h.url(),snapshot:v}}catch{}if(d.push({step_index:p,action:b.action,success:y.success,error:y.error,duration_ms:Date.now()-S,screenshot_url:I?.dataUrl,ai_context:q}),!y.success)return{id:r.id,name:r.name,status:"failed",duration_ms:Date.now()-w,error:`Step ${p+1} (${b.action}) failed: ${y.error}`,step_results:d}}for(let p=0;p<r.assertions.length;p++){let b=r.assertions[p],S=Date.now(),x;try{x=we(b)}catch(I){return d.push({step_index:r.steps.length+p,action:`assert:${b.type}`,success:!1,error:String(I),duration_ms:Date.now()-S}),{id:r.id,name:r.name,status:"failed",duration_ms:Date.now()-w,error:`Assertion ${p+1} (${b.type}) failed: ${String(I)}`,step_results:d}}let y=await xe(h,x);if(d.push({step_index:r.steps.length+p,action:`assert:${b.type}`,success:y.pass,error:y.error,duration_ms:Date.now()-S}),!y.pass)return{id:r.id,name:r.name,status:"failed",duration_ms:Date.now()-w,error:`Assertion ${p+1} (${b.type}) failed: ${y.error??"expected value mismatch"}`,step_results:d}}return{id:r.id,name:r.name,status:"passed",duration_ms:Date.now()-w,step_results:d}}catch(l){return{id:r.id,name:r.name,status:"failed",duration_ms:Date.now()-w,error:String(l),step_results:d}}}async function _e(s){try{return{dataUrl:`data:image/jpeg;base64,${await re(s,!1)}`}}catch{return}}async function z(s,e,t,r){let n=e.action;try{switch(n){case"navigate":{let i=e.url??e.value??"";return i&&!i.startsWith("http")&&(i=t.replace(/\/$/,"")+i),await X(s,i)}case"click":return await Q(s,e.selector??"");case"type":case"fill":return await Y(s,e.selector??"",e.value??"");case"fill_form":{let i=e.fields??{};return await ge(s,i)}case"drag":return await le(s,e.selector??"",e.target??"");case"resize":return await de(s,e.width??1280,e.height??720);case"hover":return await ee(s,e.selector??"");case"select":return await te(s,e.selector??"",e.value??"");case"wait_for":return e.condition==="navigation"?(await s.waitForLoadState("domcontentloaded",{timeout:(e.timeout??10)*1e3}),await s.waitForLoadState("networkidle",{timeout:5e3}).catch(()=>{}),{success:!0}):await se(s,e.selector??"",(e.timeout??10)*1e3);case"scroll":return e.selector?await s.locator(e.selector).scrollIntoViewIfNeeded():await s.evaluate(()=>window.scrollTo(0,document.body.scrollHeight)),{success:!0};case"press_key":return await oe(s,e.key??e.value??"Enter");case"upload_file":{let i=e.file_paths??(e.value?[e.value]:null);return!i||i.length===0?{success:!1,error:"upload_file step missing file_paths"}:await ce(s,e.selector??"",i)}case"evaluate":return await ue(s,e.expression??e.value??"");case"go_back":return await ie(s);case"go_forward":return await ae(s);case"restore_session":{if(!r)return{success:!1,error:"restore_session requires browser manager"};let i=e.value??e.name??"";return i?{success:!0,page:await r.restoreSession(i)}:{success:!1,error:"restore_session step missing session name (set 'value' or 'name')"}}case"save_session":{if(!r)return{success:!1,error:"save_session requires browser manager"};let i=e.value??e.name??"";return i?(await r.saveSession(i),{success:!0}):{success:!1,error:"save_session step missing session name (set 'value' or 'name')"}}case"assert":return xe(s,e).then(i=>({success:i.pass,error:i.error}));default:return{success:!1,error:`Unknown action: ${n}`}}}catch(i){return{success:!1,error:String(i)}}}async function xe(s,e){return pe(s,{type:e.type,selector:e.selector,text:e.text??e.expected_value,url:e.url,count:e.count,attribute:e.attribute,value:e.value??e.expected_value})}function Be(s){if(!s)return!1;let e=s.toLowerCase();return e.includes("navigation")||e.includes("net::")||e.includes("page.goto")?!1:e.includes("selector")||e.includes("not found")||e.includes("waiting for selector")||e.includes("no element")||e.includes("waiting for locator")||e.includes("locator")}function He(s){if(!s)return"UNKNOWN";let e=s.toLowerCase();return e.includes("timeout")?"TIMEOUT":e.includes("not found")||e.includes("no element")||e.includes("selector")?"ELEMENT_NOT_FOUND":e.includes("navigation")||e.includes("net::")?"NAVIGATION_FAILED":"UNKNOWN"}function Me(s){return s.filter(e=>{let t=e.mimeType.toLowerCase();return!!(t.includes("json")||t.includes("text/html")||t.includes("text/plain")||e.status>=400)})}function Ke(s,e){let t=new Set(s.map(a=>a.id)),r=new Set;for(let a of s)if(a.depends_on)for(let u of a.depends_on)r.add(u);let n=[],i=[];for(let a of s){let u=e[a.id],c=a.depends_on?.some(d=>t.has(d))??!1;u==="failed"&&!r.has(a.id)&&!c?n.push(a):i.push(a)}return[...n,...i]}function Ve(s){let e=new Set(s.map(c=>c.id));if(!s.some(c=>c.depends_on&&c.depends_on.some(d=>e.has(d))))return s;let r=new Map(s.map(c=>[c.id,c])),n=new Set,i=new Set,a=[];function u(c){if(n.has(c))return!0;if(i.has(c))return!1;i.add(c);let d=r.get(c);if(d?.depends_on){for(let w of d.depends_on)if(e.has(w)&&!u(w))return!1}return i.delete(c),n.add(c),d&&a.push(d),!0}for(let c of s)if(!u(c.id))return process.stderr.write(`Warning: dependency cycle detected, using original test order.
19
+ `),s;return a}var Je=Ge(Ze(import.meta.url)),Xe=(()=>{try{return JSON.parse(We(ze(Je,"..","package.json"),"utf-8")).version??"0.0.0"}catch{return"0.0.0"}})();function Qe(){let s=process.argv.slice(2),e="",t="",r="",n="https://api.fasttest.ai",i,a,u,c="chromium",d,w=!1;for(let l=0;l<s.length;l++)switch(s[l]){case"--api-key":e=s[++l]??"";break;case"--suite":r=s[++l]??"";break;case"--suite-id":t=s[++l]??"";break;case"--base-url":n=s[++l]??n;break;case"--app-url":i=s[++l];break;case"--environment":a=s[++l];break;case"--pr-url":u=s[++l];break;case"--browser":c=s[++l]??"chromium";break;case"--test-case-ids":d=(s[++l]??"").split(",").map(m=>m.trim()).filter(Boolean);break;case"--json":w=!0;break}return(!e||!t&&!r)&&(console.error(`Usage: fasttest-ci --api-key <key> --suite "Suite Name" [options]
20
+ fasttest-ci --api-key <key> --suite-id <id> [options]
21
+
22
+ Options:
23
+ --suite Suite name (resolved via API)
24
+ --suite-id Suite ID (alternative to --suite)
25
+ --base-url Cloud API base URL
26
+ --app-url Override application URL
27
+ --environment Named environment (e.g. 'staging')
28
+ --pr-url GitHub PR URL for posting results
29
+ --browser chromium | firefox | webkit
30
+ --test-case-ids Comma-separated test case IDs
31
+ --json Output results as JSON`),process.exit(1)),{apiKey:e,suiteId:t,suiteName:r||void 0,baseUrl:n,appUrl:i,environment:a,prUrl:u,browser:c,testCaseIds:d,json:w}}function Ye(s){let e=s.status==="passed"?"PASSED":"FAILED";console.log(`--- Results: ${e} ---`),console.log(`Execution: ${s.execution_id}`),console.log(`Total: ${s.total} | Passed: ${s.passed} | Failed: ${s.failed} | Skipped: ${s.skipped}`),console.log(`Duration: ${(s.duration_ms/1e3).toFixed(1)}s`),console.log("");for(let t of s.results){let r=t.status==="passed"?"PASS":t.status==="failed"?"FAIL":"SKIP";console.log(` [${r}] ${t.name} (${t.duration_ms}ms)`),t.error&&console.log(` Error: ${t.error}`)}if(s.healed.length>0){console.log(""),console.log(`--- Self-Healed: ${s.healed.length} selector(s) ---`);for(let t of s.healed)console.log(` "${t.test_case}" step ${t.step_index+1}`),console.log(` ${t.original_selector} -> ${t.new_selector}`),console.log(` Strategy: ${t.strategy} (${Math.round(t.confidence*100)}% confidence)`)}}async function et(){let s=Qe(),e=U(s.apiKey.split("_")[1]??"default"),t=new L({browserType:s.browser,headless:!0,orgSlug:e});G=t;let r=new B({apiKey:s.apiKey,baseUrl:s.baseUrl}),n=[];if(console.log(`FastTest CI Runner v${Xe}`),!s.suiteId&&s.suiteName)try{let u=await r.resolveSuite(s.suiteName);s.suiteId=u.id,console.log(`Suite: "${u.name}" \u2192 ${u.id}`)}catch(u){console.error(`Failed to resolve suite "${s.suiteName}": ${u}`),process.exit(1)}else console.log(`Suite: ${s.suiteId}`);console.log(`Browser: ${s.browser}`),s.environment&&console.log(`Environment: ${s.environment}`),s.appUrl&&console.log(`App URL: ${s.appUrl}`),console.log("");let i;if(s.environment)try{let u=await r.resolveEnvironment(s.suiteId,s.environment);i=u.id,console.log(`Resolved environment "${s.environment}" \u2192 ${u.base_url}`)}catch(u){console.error(`Failed to resolve environment "${s.environment}": ${u}`),process.exit(1)}let a;try{a=await ye(t,r,{suiteId:s.suiteId,testCaseIds:s.testCaseIds,appUrlOverride:s.appUrl,environmentId:i},n)}catch(u){console.error(`Fatal: ${u}`),await be(t),process.exit(1)}if(s.json?console.log(JSON.stringify(a,null,2)):Ye(a),s.prUrl)try{let u,c;try{let l=await r.getExecutionDiff(a.execution_id);l.regressions?.length&&(u=l.regressions.map(m=>({name:m.name,previous_status:m.previous_status,current_status:m.current_status,error:m.error}))),l.fixes?.length&&(c=l.fixes.map(m=>({name:m.name,previous_status:m.previous_status,current_status:m.current_status})))}catch{}let w=(await r.postPrComment({pr_url:s.prUrl,execution_id:a.execution_id,status:a.status,total:a.total,passed:a.passed,failed:a.failed,skipped:a.skipped,duration_seconds:Math.round(a.duration_ms/1e3),test_results:a.results.map(l=>({name:l.name,status:l.status,error:l.error})),healed:a.healed.map(l=>({original_selector:l.original_selector,new_selector:l.new_selector,strategy:l.strategy,confidence:l.confidence})),regressions:u,fixes:c})).comment_url;console.log(`
32
+ PR comment posted: ${w??s.prUrl}`)}catch(u){console.error(`
33
+ Failed to post PR comment: ${u}`)}await be(t),process.exit(a.status==="passed"?0:1)}async function be(s){await Promise.race([s.close(),new Promise(e=>setTimeout(e,5e3))])}var G=null;async function ve(s){console.log(`
34
+ ${s} received, shutting down\u2026`),G&&await Promise.race([G.close(),new Promise(e=>setTimeout(e,5e3))]),process.exit(0)}process.on("SIGTERM",()=>ve("SIGTERM"));process.on("SIGINT",()=>ve("SIGINT"));et().catch(s=>{console.error("Fatal:",s),process.exit(1)});