twd-relay 0.3.0 → 1.1.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
@@ -18,9 +18,20 @@ Your app runs tests in the browser with twd-js. twd-relay adds a relay server an
18
18
 
19
19
  1. Browser connects → sends `{ type: 'hello', role: 'browser' }`
20
20
  2. Client connects → sends `{ type: 'hello', role: 'client' }`
21
- 3. Client sends `{ type: 'run', scope: 'all' }` → relay forwards to browser
21
+ 3. Client sends `{ type: 'run', scope: 'all' }` (optionally with `testNames` to filter) → relay forwards to browser
22
22
  4. Browser runs tests and streams events → relay broadcasts to clients
23
- 5. `run:complete` clears the run lock (and the send-run script exits)
23
+ 5. Browser sends `{ type: 'heartbeat' }` every 3s during a run (relay consumes these, never forwarded to clients)
24
+ 6. `run:complete` clears the run lock (and the send-run script exits)
25
+
26
+ ### Heartbeat & frozen-tab recovery
27
+
28
+ During an active test run the browser sends a heartbeat every 3 seconds. The relay tracks the last heartbeat time and checks every 10 seconds. If no heartbeat arrives for **120 seconds** during an active run, the relay considers the run dead (browser tab frozen by the OS), resets the run lock, and broadcasts to all clients:
29
+
30
+ ```json
31
+ { "type": "run:abandoned", "reason": "heartbeat_timeout" }
32
+ ```
33
+
34
+ The CLI prints a clear message — `Run abandoned — browser tab appears frozen. Refresh the browser tab and retry.` — and exits with code 1. This is especially useful for AI agent workflows, where the agent gets an actionable signal instead of a silent 180s timeout followed by a cryptic `RUN_IN_PROGRESS` error.
24
35
 
25
36
  ---
26
37
 
@@ -57,6 +68,32 @@ const client = createBrowserClient({
57
68
  client.connect();
58
69
  ```
59
70
 
71
+ Once connected, the browser client sets a colored favicon and prefixes `document.title` so you can spot the active TWD tab at a glance:
72
+
73
+ | Favicon | Title prefix | State |
74
+ |---|---|---|
75
+ | Blue | `[TWD]` | Connected, idle |
76
+ | Orange | `[TWD ...]` | Tests running |
77
+ | Green | `[TWD ✓]` | Last run passed |
78
+ | Red | `[TWD ✗]` | Last run had failures |
79
+
80
+ On disconnect or eviction (another tab taking over), the original favicon and title are restored.
81
+
82
+ ### Aborting throttled runs
83
+
84
+ Chrome aggressively throttles timers in backgrounded tabs, which can stretch a 1-second test run to 30+ seconds. To avoid AI/CI hangs, the browser client monitors per-test wall-clock time. If any single test runs longer than 5 seconds (configurable), the browser emits `run:aborted`, the CLI prints a clear error with recovery guidance, and the run ends with exit code 1.
85
+
86
+ Override the threshold with `--max-test-duration <ms>` on `twd-relay run`, or pass `maxTestDurationMs` to `createBrowserClient`. Set it to `0` to disable detection entirely:
87
+
88
+ ```bash
89
+ twd-relay run --max-test-duration 15000 # raise to 15s for heavy multistep tests
90
+ twd-relay run --max-test-duration 0 # disable detection
91
+ ```
92
+
93
+ The default of 5 s is deliberate: real TWD tests are typically sub-second, and 5 s is a strong throttling signal. Heavy legitimate tests (complex multistep forms, many API calls) may need to raise this.
94
+
95
+ Recovery when an abort fires: foreground the TWD tab (identified by the `[TWD …]` title prefix set by the favicon indicator) and retry. For unattended runs (CI, agents), prefer `twd-cli`: it drives a headless browser where the tab is always focused and throttling doesn't apply.
96
+
60
97
  **3. Open your app in a browser** — the page connects to the relay as “browser”.
61
98
 
62
99
  **4. Trigger a run** — something must connect as a **client** and send `run`:
@@ -114,7 +151,40 @@ Then in your app you can omit the URL; the client defaults to `ws(s)://<current
114
151
  | `twd-relay/browser` | Browser client: `createBrowserClient(options)` |
115
152
  | `twd-relay/vite` | Vite plugin: `twdRemote(options)` |
116
153
 
117
- CLI: `twd-relay` (or `npx twd-relay`) runs the standalone relay; supports `--port`.
154
+ CLI: `twd-relay` (or `npx twd-relay`) two subcommands:
155
+
156
+ - `twd-relay serve` (default) — start the standalone relay
157
+ - `twd-relay run` — connect to a relay and trigger a test run
158
+
159
+ ---
160
+
161
+ ## CLI `run` command
162
+
163
+ Connect to an existing relay, trigger tests, stream output, and exit with 0 (all pass) or 1 (failures).
164
+
165
+ ```bash
166
+ # Run all tests (connects to Vite dev server on port 5173 by default)
167
+ twd-relay run
168
+
169
+ # Run on a different port
170
+ twd-relay run --port 9876
171
+
172
+ # Run specific tests by name (substring match, case-insensitive)
173
+ twd-relay run --test "should show error"
174
+
175
+ # Multiple filters — runs tests matching any of them
176
+ twd-relay run --test "login" --test "signup"
177
+ ```
178
+
179
+ | Flag | Description | Default |
180
+ |------|-------------|---------|
181
+ | `--port <port>` | Relay port | 5173 |
182
+ | `--host <host>` | Relay host | localhost |
183
+ | `--path <path>` | WebSocket path | `/__twd/ws` |
184
+ | `--timeout <ms>` | Timeout | 180000 |
185
+ | `--test <name>` | Filter tests by name substring (repeatable) | — |
186
+
187
+ When `--test` is used and no tests match, the CLI prints the available test names so you can correct the filter.
118
188
 
119
189
  ---
120
190
 
@@ -1 +1 @@
1
- "use strict";var P=Object.create;var C=Object.defineProperty;var A=Object.getOwnPropertyDescriptor;var L=Object.getOwnPropertyNames;var $=Object.getPrototypeOf,z=Object.prototype.hasOwnProperty;var I=(n,o,i,f)=>{if(o&&typeof o=="object"||typeof o=="function")for(let l of L(o))!z.call(n,l)&&l!==i&&C(n,l,{get:()=>o[l],enumerable:!(f=A(o,l))||f.enumerable});return n};var M=(n,o,i)=>(i=n!=null?P($(n)):{},I(o||!n||!n.__esModule?C(i,"default",{value:n,enumerable:!0}):i,n));Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function j(n){return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}${n}`}function p(n,o){if(!n.parent)return"";const i=o.get(n.parent);return i?i.name:""}function B(n){const o=n?.url??j(n?.path??"/__twd/ws"),i=n?.reconnect??!0,f=n?.reconnectInterval??2e3,l=n?.log??!1,T="[twd-relay]";function m(...t){l&&console.info(T,...t)}function S(...t){console.warn(T,...t)}let r=null,w=!1,g=null;function a(t){r&&r.readyState===WebSocket.OPEN&&r.send(JSON.stringify(t))}function u(){window.dispatchEvent(new CustomEvent("twd:state-change"))}async function N(){const t=window.__TWD_STATE__;if(!t){S("TWD not initialized — make sure twd-js is loaded before running tests"),a({type:"error",code:"NO_TWD",message:"TWD not initialized"});return}const s=t.handlers,y=Array.from(s.values()).filter(e=>e.type==="test").length;a({type:"run:start",testCount:y});let c=0,_=0,b=0;const h=performance.now(),O={onStart(e){e.status="running",u(),a({type:"test:start",id:e.id,name:e.name,suite:p(e,s)})},onPass(e){c++,e.status="pass",u(),a({type:"test:pass",id:e.id,name:e.name,suite:p(e,s),duration:performance.now()-h})},onFail(e,d){_++,e.status="fail",e.logs=[d.message],u(),a({type:"test:fail",id:e.id,name:e.name,suite:p(e,s),error:d.message,duration:performance.now()-h})},onSkip(e){b++,e.status="skip",u(),a({type:"test:skip",id:e.id,name:e.name,suite:p(e,s)})},onSuiteStart(e){e.status="running",u()},onSuiteEnd(e){e.status="idle",u()}};try{const{TestRunner:e}=await import("twd-js/runner");await new e(O).runAll()}catch(e){const d=e instanceof Error?e.message:String(e);S("Runner error:",d),a({type:"error",code:"RUNNER_ERROR",message:d})}const D=performance.now()-h;a({type:"run:complete",passed:c,failed:_,skipped:b,duration:D}),u()}function R(){const t=window.__TWD_STATE__;if(!t){a({type:"error",code:"NO_TWD",message:"TWD not initialized"});return}const s=t.handlers,y=[];for(const[,c]of s)c.type==="test"&&y.push({id:c.id,name:c.name,suite:p(c,s),status:c.status??"idle"});a({type:"status:result",tests:y})}function W(t){let s;try{s=JSON.parse(t.data)}catch{return}s.type==="run"?(m("Received run command — running tests..."),N()):s.type==="status"&&R()}function k(){i&&!w&&(m(`Reconnecting in ${f}ms...`),g=setTimeout(()=>{E()},f))}function E(){r&&(r.readyState===WebSocket.OPEN||r.readyState===WebSocket.CONNECTING)||(w=!1,m("Connecting to",o),r=new WebSocket(o),r.addEventListener("open",()=>{a({type:"hello",role:"browser"}),m("Connected to relay — ready to receive run/status commands")}),r.addEventListener("message",W),r.addEventListener("close",t=>{if(r=null,t.reason==="Replaced by new browser"){S("Another browser instance connected — this instance will not reconnect");return}w||m("Disconnected",t.code?`(code ${t.code})`:"",t.reason||""),k()}),r.addEventListener("error",()=>{}))}function v(){w=!0,g&&(clearTimeout(g),g=null),r&&(r.close(1e3,"Client disconnecting"),r=null)}return{connect:E,disconnect:v,get connected(){return r!==null&&r.readyState===WebSocket.OPEN}}}exports.createBrowserClient=B;
1
+ var z=Object.create;var R=Object.defineProperty;var U=Object.getOwnPropertyDescriptor;var q=Object.getOwnPropertyNames;var H=Object.getPrototypeOf,G=Object.prototype.hasOwnProperty;var V=(e,r,s,c)=>{if(r&&typeof r=="object"||typeof r=="function")for(let n of q(r))!G.call(e,n)&&n!==s&&R(e,n,{get:()=>r[n],enumerable:!(c=U(r,n))||c.enumerable});return e};var X=(e,r,s)=>(s=e!=null?z(H(e)):{},V(r||!e||!e.__esModule?R(s,"default",{value:e,enumerable:!0}):s,e));Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});var M=e=>`data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='3' y='4' width='26' height='24' rx='4' fill='${e}'/><circle cx='7' cy='9' r='1' fill='white'/><circle cx='11' cy='9' r='1' fill='white'/><circle cx='15' cy='9' r='1' fill='white'/><path d='M9 20l4 4 10-10' stroke='white' stroke-width='3.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>`,K={connected:M("%234A90D9"),running:M("%23F5A623"),pass:M("%237ED321"),fail:M("%23D0021B")},Q={connected:"[TWD] ",running:"[TWD ...] ",pass:"[TWD ✓] ",fail:"[TWD ✗] "};function Y(e){let r=null,s=null,c=!1,n=null;function b(){if(n&&n.isConnected)return n;const i=e.querySelector("link[rel='icon']");if(i)n=i;else{const f=e.createElement("link");f.rel="icon",e.head.appendChild(f),n=f}return n}return{save(){if(s!==null)return;const i=e.querySelector("link[rel='icon']");c=i!==null,r=i?.href??null,s=e.title,n=i},restore(){s!==null&&(e.title=s),!c&&n&&n.isConnected?n.remove():c&&r!==null&&n&&n.setAttribute("href",r),r=null,s=null,c=!1,n=null},set(i){b().setAttribute("href",K[i]);const f=s??e.title;e.title=Q[i]+f}}}function Z(e){const r=e.now??(()=>performance.now()),s=e.thresholdMs;let c=null,n=null,b=!1;return{onTestStart(i){c=r(),n=i},onTestEnd(){if(c===null||n===null)return null;const i=n,f=r()-c;return c=null,n=null,s<=0||f<=s?null:{testName:i,durationMs:f}},checkThreshold(){if(s<=0||c===null||n===null)return null;const i=r()-c;return i<=s?null:{testName:n,durationMs:i}},markAborted(){b=!0},isAborted(){return b}}}function ee(e){return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}${e}`}function E(e,r){if(!e.parent)return"";const s=r.get(e.parent);return s?s.name:""}function te(e){const r=e?.url??ee(e?.path??"/__twd/ws"),s=e?.reconnect??!0,c=e?.reconnectInterval??2e3,n=e?.log??!1,b=e?.maxTestDurationMs??5e3,i="[twd-relay]";function f(...o){n&&console.info(i,...o)}function _(...o){console.warn(i,...o)}const w=Y(document);let u=null,k=!1,N=null;function d(o){u&&u.readyState===WebSocket.OPEN&&u.send(JSON.stringify(o))}function g(){window.dispatchEvent(new CustomEvent("twd:state-change"))}async function I(o,m={}){const a=Z({thresholdMs:typeof m.maxTestDurationMs=="number"?m.maxTestDurationMs:b});let h=0,S=0,W=0;const C=performance.now();let x;function D(y){a.isAborted()||(a.markAborted(),d({type:"run:aborted",reason:"throttled",durationMs:y.durationMs,testName:y.testName}),d({type:"run:complete",passed:h,failed:S,skipped:W,duration:performance.now()-C}),w.set("fail"),g(),clearInterval(x))}x=setInterval(()=>{d({type:"heartbeat"});const y=a.checkThreshold();y&&D(y)},3e3),w.set("running");try{const y=window.__TWD_STATE__;if(!y){_("TWD not initialized — make sure twd-js is loaded before running tests"),d({type:"error",code:"NO_TWD",message:"TWD not initialized"});return}const T=y.handlers;let v;if(o&&o.length>0){const t=o.map(p=>p.toLowerCase()),l=[];for(const[,p]of T)if(p.type==="test"){const A=p.name.toLowerCase();t.some(j=>A.includes(j))&&l.push(p.id)}if(l.length===0){const p=Array.from(T.values()).filter(A=>A.type==="test").map(A=>A.name);d({type:"run:start",testCount:0}),d({type:"error",code:"NO_MATCH",message:`No tests matched: ${JSON.stringify(o)}. Available tests: ${JSON.stringify(p)}`}),d({type:"run:complete",passed:0,failed:0,skipped:0,duration:0}),w.set("pass");return}v=l}d({type:"run:start",testCount:v?v.length:Array.from(T.values()).filter(t=>t.type==="test").length});const F={onStart(t){a.isAborted()||(a.onTestStart(t.name),t.status="running",g(),d({type:"test:start",id:t.id,name:t.name,suite:E(t,T)}))},onPass(t){const l=a.onTestEnd();if(!a.isAborted()){if(l){D(l);return}h++,t.status="pass",g(),d({type:"test:pass",id:t.id,name:t.name,suite:E(t,T),duration:performance.now()-C})}},onFail(t,l){const p=a.onTestEnd();if(!a.isAborted()){if(p){D(p);return}S++,t.status="fail",t.logs=[l.message],g(),d({type:"test:fail",id:t.id,name:t.name,suite:E(t,T),error:l.message,duration:performance.now()-C})}},onSkip(t){const l=a.onTestEnd();if(!a.isAborted()){if(l){D(l);return}W++,t.status="skip",g(),d({type:"test:skip",id:t.id,name:t.name,suite:E(t,T)})}},onSuiteStart(t){a.isAborted()||(t.status="running",g())},onSuiteEnd(t){a.isAborted()||(t.status="idle",g())}};try{const{TestRunner:t}=await import("twd-js/runner"),l=new t(F);v?await l.runByIds(v):await l.runAll()}catch(t){const l=t instanceof Error?t.message:String(t);_("Runner error:",l),d({type:"error",code:"RUNNER_ERROR",message:l}),S++,a.onTestEnd()}const J=performance.now()-C;a.isAborted()?w.set("fail"):(d({type:"run:complete",passed:h,failed:S,skipped:W,duration:J}),w.set(S>0?"fail":"pass")),g()}finally{clearInterval(x)}}function L(){const o=window.__TWD_STATE__;if(!o){d({type:"error",code:"NO_TWD",message:"TWD not initialized"});return}const m=o.handlers,a=[];for(const[,h]of m)h.type==="test"&&a.push({id:h.id,name:h.name,suite:E(h,m),status:h.status??"idle"});d({type:"status:result",tests:a})}function $(o){let m;try{m=JSON.parse(o.data)}catch{return}m.type==="run"?(f("Received run command — running tests..."),I(Array.isArray(m.testNames)?m.testNames:void 0,m)):m.type==="status"&&L()}function P(){s&&!k&&(f(`Reconnecting in ${c}ms...`),N=setTimeout(()=>{O()},c))}function O(){u&&(u.readyState===WebSocket.OPEN||u.readyState===WebSocket.CONNECTING)||(k=!1,f("Connecting to",r),u=new WebSocket(r),u.addEventListener("open",()=>{d({type:"hello",role:"browser"}),w.save(),w.set("connected"),f("Connected to relay — ready to receive run/status commands")}),u.addEventListener("message",$),u.addEventListener("close",o=>{if(u=null,w.restore(),o.reason==="Replaced by new browser"){_("Another browser instance connected — this instance will not reconnect");return}k||f("Disconnected",o.code?`(code ${o.code})`:"",o.reason||""),P()}),u.addEventListener("error",()=>{}))}function B(){k=!0,N&&(clearTimeout(N),N=null),u&&(u.close(1e3,"Client disconnecting"),u=null)}return{connect:O,disconnect:B,get connected(){return u!==null&&u.readyState===WebSocket.OPEN}}}exports.createBrowserClient=te;
package/dist/browser.d.ts CHANGED
@@ -15,6 +15,13 @@ export declare interface BrowserClientOptions {
15
15
  reconnectInterval?: number;
16
16
  /** Enable console logging. Default: false */
17
17
  log?: boolean;
18
+ /**
19
+ * Maximum wall-clock ms any single test may run before the browser
20
+ * aborts the run with reason 'throttled'. Typically triggered when the
21
+ * tab is backgrounded and Chrome throttles timers. Default: 5000.
22
+ * Set to 0 to disable detection.
23
+ */
24
+ maxTestDurationMs?: number;
18
25
  }
19
26
 
20
27
  export declare function createBrowserClient(options?: BrowserClientOptions): BrowserClient;
@@ -1,141 +1,312 @@
1
- function O(s) {
2
- return `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}${s}`;
1
+ var M = (t) => `data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='3' y='4' width='26' height='24' rx='4' fill='${t}'/><circle cx='7' cy='9' r='1' fill='white'/><circle cx='11' cy='9' r='1' fill='white'/><circle cx='15' cy='9' r='1' fill='white'/><path d='M9 20l4 4 10-10' stroke='white' stroke-width='3.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>`, z = {
2
+ connected: M("%234A90D9"),
3
+ running: M("%23F5A623"),
4
+ pass: M("%237ED321"),
5
+ fail: M("%23D0021B")
6
+ }, U = {
7
+ connected: "[TWD] ",
8
+ running: "[TWD ...] ",
9
+ pass: "[TWD ✓] ",
10
+ fail: "[TWD ✗] "
11
+ };
12
+ function j(t) {
13
+ let f = null, c = null, u = !1, n = null;
14
+ function b() {
15
+ if (n && n.isConnected) return n;
16
+ const s = t.querySelector("link[rel='icon']");
17
+ if (s) n = s;
18
+ else {
19
+ const d = t.createElement("link");
20
+ d.rel = "icon", t.head.appendChild(d), n = d;
21
+ }
22
+ return n;
23
+ }
24
+ return {
25
+ save() {
26
+ if (c !== null) return;
27
+ const s = t.querySelector("link[rel='icon']");
28
+ u = s !== null, f = s?.href ?? null, c = t.title, n = s;
29
+ },
30
+ restore() {
31
+ c !== null && (t.title = c), !u && n && n.isConnected ? n.remove() : u && f !== null && n && n.setAttribute("href", f), f = null, c = null, u = !1, n = null;
32
+ },
33
+ set(s) {
34
+ b().setAttribute("href", z[s]);
35
+ const d = c ?? t.title;
36
+ t.title = U[s] + d;
37
+ }
38
+ };
3
39
  }
4
- function d(s, u) {
5
- if (!s.parent) return "";
6
- const f = u.get(s.parent);
7
- return f ? f.name : "";
40
+ function q(t) {
41
+ const f = t.now ?? (() => performance.now()), c = t.thresholdMs;
42
+ let u = null, n = null, b = !1;
43
+ return {
44
+ onTestStart(s) {
45
+ u = f(), n = s;
46
+ },
47
+ onTestEnd() {
48
+ if (u === null || n === null) return null;
49
+ const s = n, d = f() - u;
50
+ return u = null, n = null, c <= 0 || d <= c ? null : {
51
+ testName: s,
52
+ durationMs: d
53
+ };
54
+ },
55
+ checkThreshold() {
56
+ if (c <= 0 || u === null || n === null) return null;
57
+ const s = f() - u;
58
+ return s <= c ? null : {
59
+ testName: n,
60
+ durationMs: s
61
+ };
62
+ },
63
+ markAborted() {
64
+ b = !0;
65
+ },
66
+ isAborted() {
67
+ return b;
68
+ }
69
+ };
70
+ }
71
+ function H(t) {
72
+ return `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}${t}`;
8
73
  }
9
- function A(s) {
10
- const u = s?.url ?? O(s?.path ?? "/__twd/ws"), f = s?.reconnect ?? !0, S = s?.reconnectInterval ?? 2e3, N = s?.log ?? !1, h = "[twd-relay]";
11
- function l(...n) {
12
- N && console.info(h, ...n);
74
+ function E(t, f) {
75
+ if (!t.parent) return "";
76
+ const c = f.get(t.parent);
77
+ return c ? c.name : "";
78
+ }
79
+ function G(t) {
80
+ const f = t?.url ?? H(t?.path ?? "/__twd/ws"), c = t?.reconnect ?? !0, u = t?.reconnectInterval ?? 2e3, n = t?.log ?? !1, b = t?.maxTestDurationMs ?? 5e3, s = "[twd-relay]";
81
+ function d(...r) {
82
+ n && console.info(s, ...r);
13
83
  }
14
- function g(...n) {
15
- console.warn(h, ...n);
84
+ function _(...r) {
85
+ console.warn(s, ...r);
16
86
  }
17
- let t = null, p = !1, m = null;
18
- function o(n) {
19
- t && t.readyState === WebSocket.OPEN && t.send(JSON.stringify(n));
87
+ const w = j(document);
88
+ let a = null, k = !1, N = null;
89
+ function l(r) {
90
+ a && a.readyState === WebSocket.OPEN && a.send(JSON.stringify(r));
20
91
  }
21
- function i() {
92
+ function g() {
22
93
  window.dispatchEvent(new CustomEvent("twd:state-change"));
23
94
  }
24
- async function C() {
25
- const n = window.__TWD_STATE__;
26
- if (!n) {
27
- g("TWD not initialized — make sure twd-js is loaded before running tests"), o({ type: "error", code: "NO_TWD", message: "TWD not initialized" });
28
- return;
95
+ async function I(r, m = {}) {
96
+ const o = q({ thresholdMs: typeof m.maxTestDurationMs == "number" ? m.maxTestDurationMs : b });
97
+ let h = 0, S = 0, x = 0;
98
+ const C = performance.now();
99
+ let W;
100
+ function D(y) {
101
+ o.isAborted() || (o.markAborted(), l({
102
+ type: "run:aborted",
103
+ reason: "throttled",
104
+ durationMs: y.durationMs,
105
+ testName: y.testName
106
+ }), l({
107
+ type: "run:complete",
108
+ passed: h,
109
+ failed: S,
110
+ skipped: x,
111
+ duration: performance.now() - C
112
+ }), w.set("fail"), g(), clearInterval(W));
29
113
  }
30
- const r = n.handlers, w = Array.from(r.values()).filter((e) => e.type === "test").length;
31
- o({ type: "run:start", testCount: w });
32
- let a = 0, T = 0, _ = 0;
33
- const y = performance.now(), v = {
34
- onStart(e) {
35
- e.status = "running", i(), o({
36
- type: "test:start",
37
- id: e.id,
38
- name: e.name,
39
- suite: d(e, r)
40
- });
41
- },
42
- onPass(e) {
43
- a++, e.status = "pass", i(), o({
44
- type: "test:pass",
45
- id: e.id,
46
- name: e.name,
47
- suite: d(e, r),
48
- duration: performance.now() - y
49
- });
50
- },
51
- onFail(e, c) {
52
- T++, e.status = "fail", e.logs = [c.message], i(), o({
53
- type: "test:fail",
54
- id: e.id,
55
- name: e.name,
56
- suite: d(e, r),
57
- error: c.message,
58
- duration: performance.now() - y
59
- });
60
- },
61
- onSkip(e) {
62
- _++, e.status = "skip", i(), o({
63
- type: "test:skip",
64
- id: e.id,
65
- name: e.name,
66
- suite: d(e, r)
114
+ W = setInterval(() => {
115
+ l({ type: "heartbeat" });
116
+ const y = o.checkThreshold();
117
+ y && D(y);
118
+ }, 3e3), w.set("running");
119
+ try {
120
+ const y = window.__TWD_STATE__;
121
+ if (!y) {
122
+ _("TWD not initialized — make sure twd-js is loaded before running tests"), l({
123
+ type: "error",
124
+ code: "NO_TWD",
125
+ message: "TWD not initialized"
67
126
  });
68
- },
69
- onSuiteStart(e) {
70
- e.status = "running", i();
71
- },
72
- onSuiteEnd(e) {
73
- e.status = "idle", i();
127
+ return;
74
128
  }
75
- };
76
- try {
77
- const { TestRunner: e } = await import("twd-js/runner");
78
- await new e(v).runAll();
79
- } catch (e) {
80
- const c = e instanceof Error ? e.message : String(e);
81
- g("Runner error:", c), o({ type: "error", code: "RUNNER_ERROR", message: c });
129
+ const T = y.handlers;
130
+ let v;
131
+ if (r && r.length > 0) {
132
+ const e = r.map((p) => p.toLowerCase()), i = [];
133
+ for (const [, p] of T) if (p.type === "test") {
134
+ const A = p.name.toLowerCase();
135
+ e.some((J) => A.includes(J)) && i.push(p.id);
136
+ }
137
+ if (i.length === 0) {
138
+ const p = Array.from(T.values()).filter((A) => A.type === "test").map((A) => A.name);
139
+ l({
140
+ type: "run:start",
141
+ testCount: 0
142
+ }), l({
143
+ type: "error",
144
+ code: "NO_MATCH",
145
+ message: `No tests matched: ${JSON.stringify(r)}. Available tests: ${JSON.stringify(p)}`
146
+ }), l({
147
+ type: "run:complete",
148
+ passed: 0,
149
+ failed: 0,
150
+ skipped: 0,
151
+ duration: 0
152
+ }), w.set("pass");
153
+ return;
154
+ }
155
+ v = i;
156
+ }
157
+ l({
158
+ type: "run:start",
159
+ testCount: v ? v.length : Array.from(T.values()).filter((e) => e.type === "test").length
160
+ });
161
+ const F = {
162
+ onStart(e) {
163
+ o.isAborted() || (o.onTestStart(e.name), e.status = "running", g(), l({
164
+ type: "test:start",
165
+ id: e.id,
166
+ name: e.name,
167
+ suite: E(e, T)
168
+ }));
169
+ },
170
+ onPass(e) {
171
+ const i = o.onTestEnd();
172
+ if (!o.isAborted()) {
173
+ if (i) {
174
+ D(i);
175
+ return;
176
+ }
177
+ h++, e.status = "pass", g(), l({
178
+ type: "test:pass",
179
+ id: e.id,
180
+ name: e.name,
181
+ suite: E(e, T),
182
+ duration: performance.now() - C
183
+ });
184
+ }
185
+ },
186
+ onFail(e, i) {
187
+ const p = o.onTestEnd();
188
+ if (!o.isAborted()) {
189
+ if (p) {
190
+ D(p);
191
+ return;
192
+ }
193
+ S++, e.status = "fail", e.logs = [i.message], g(), l({
194
+ type: "test:fail",
195
+ id: e.id,
196
+ name: e.name,
197
+ suite: E(e, T),
198
+ error: i.message,
199
+ duration: performance.now() - C
200
+ });
201
+ }
202
+ },
203
+ onSkip(e) {
204
+ const i = o.onTestEnd();
205
+ if (!o.isAborted()) {
206
+ if (i) {
207
+ D(i);
208
+ return;
209
+ }
210
+ x++, e.status = "skip", g(), l({
211
+ type: "test:skip",
212
+ id: e.id,
213
+ name: e.name,
214
+ suite: E(e, T)
215
+ });
216
+ }
217
+ },
218
+ onSuiteStart(e) {
219
+ o.isAborted() || (e.status = "running", g());
220
+ },
221
+ onSuiteEnd(e) {
222
+ o.isAborted() || (e.status = "idle", g());
223
+ }
224
+ };
225
+ try {
226
+ const { TestRunner: e } = await import("twd-js/runner"), i = new e(F);
227
+ v ? await i.runByIds(v) : await i.runAll();
228
+ } catch (e) {
229
+ const i = e instanceof Error ? e.message : String(e);
230
+ _("Runner error:", i), l({
231
+ type: "error",
232
+ code: "RUNNER_ERROR",
233
+ message: i
234
+ }), S++, o.onTestEnd();
235
+ }
236
+ const B = performance.now() - C;
237
+ o.isAborted() ? w.set("fail") : (l({
238
+ type: "run:complete",
239
+ passed: h,
240
+ failed: S,
241
+ skipped: x,
242
+ duration: B
243
+ }), w.set(S > 0 ? "fail" : "pass")), g();
244
+ } finally {
245
+ clearInterval(W);
82
246
  }
83
- const D = performance.now() - y;
84
- o({ type: "run:complete", passed: a, failed: T, skipped: _, duration: D }), i();
85
247
  }
86
- function R() {
87
- const n = window.__TWD_STATE__;
88
- if (!n) {
89
- o({ type: "error", code: "NO_TWD", message: "TWD not initialized" });
248
+ function O() {
249
+ const r = window.__TWD_STATE__;
250
+ if (!r) {
251
+ l({
252
+ type: "error",
253
+ code: "NO_TWD",
254
+ message: "TWD not initialized"
255
+ });
90
256
  return;
91
257
  }
92
- const r = n.handlers, w = [];
93
- for (const [, a] of r)
94
- a.type === "test" && w.push({
95
- id: a.id,
96
- name: a.name,
97
- suite: d(a, r),
98
- status: a.status ?? "idle"
99
- });
100
- o({ type: "status:result", tests: w });
258
+ const m = r.handlers, o = [];
259
+ for (const [, h] of m) h.type === "test" && o.push({
260
+ id: h.id,
261
+ name: h.name,
262
+ suite: E(h, m),
263
+ status: h.status ?? "idle"
264
+ });
265
+ l({
266
+ type: "status:result",
267
+ tests: o
268
+ });
101
269
  }
102
- function W(n) {
103
- let r;
270
+ function L(r) {
271
+ let m;
104
272
  try {
105
- r = JSON.parse(n.data);
273
+ m = JSON.parse(r.data);
106
274
  } catch {
107
275
  return;
108
276
  }
109
- r.type === "run" ? (l("Received run command — running tests..."), C()) : r.type === "status" && R();
277
+ m.type === "run" ? (d("Received run command — running tests..."), I(Array.isArray(m.testNames) ? m.testNames : void 0, m)) : m.type === "status" && O();
110
278
  }
111
- function b() {
112
- f && !p && (l(`Reconnecting in ${S}ms...`), m = setTimeout(() => {
113
- E();
114
- }, S));
279
+ function $() {
280
+ c && !k && (d(`Reconnecting in ${u}ms...`), N = setTimeout(() => {
281
+ R();
282
+ }, u));
115
283
  }
116
- function E() {
117
- t && (t.readyState === WebSocket.OPEN || t.readyState === WebSocket.CONNECTING) || (p = !1, l("Connecting to", u), t = new WebSocket(u), t.addEventListener("open", () => {
118
- o({ type: "hello", role: "browser" }), l("Connected to relay — ready to receive run/status commands");
119
- }), t.addEventListener("message", W), t.addEventListener("close", (n) => {
120
- if (t = null, n.reason === "Replaced by new browser") {
121
- g("Another browser instance connectedthis instance will not reconnect");
284
+ function R() {
285
+ a && (a.readyState === WebSocket.OPEN || a.readyState === WebSocket.CONNECTING) || (k = !1, d("Connecting to", f), a = new WebSocket(f), a.addEventListener("open", () => {
286
+ l({
287
+ type: "hello",
288
+ role: "browser"
289
+ }), w.save(), w.set("connected"), d("Connected to relayready to receive run/status commands");
290
+ }), a.addEventListener("message", L), a.addEventListener("close", (r) => {
291
+ if (a = null, w.restore(), r.reason === "Replaced by new browser") {
292
+ _("Another browser instance connected — this instance will not reconnect");
122
293
  return;
123
294
  }
124
- p || l("Disconnected", n.code ? `(code ${n.code})` : "", n.reason || ""), b();
125
- }), t.addEventListener("error", () => {
295
+ k || d("Disconnected", r.code ? `(code ${r.code})` : "", r.reason || ""), $();
296
+ }), a.addEventListener("error", () => {
126
297
  }));
127
298
  }
128
- function k() {
129
- p = !0, m && (clearTimeout(m), m = null), t && (t.close(1e3, "Client disconnecting"), t = null);
299
+ function P() {
300
+ k = !0, N && (clearTimeout(N), N = null), a && (a.close(1e3, "Client disconnecting"), a = null);
130
301
  }
131
302
  return {
132
- connect: E,
133
- disconnect: k,
303
+ connect: R,
304
+ disconnect: P,
134
305
  get connected() {
135
- return t !== null && t.readyState === WebSocket.OPEN;
306
+ return a !== null && a.readyState === WebSocket.OPEN;
136
307
  }
137
308
  };
138
309
  }
139
310
  export {
140
- A as createBrowserClient
311
+ G as createBrowserClient
141
312
  };