twd-relay 1.0.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
- var I=Object.create;var O=Object.defineProperty;var J=Object.getOwnPropertyDescriptor;var M=Object.getOwnPropertyNames;var z=Object.getPrototypeOf,B=Object.prototype.hasOwnProperty;var j=(t,o,l,p)=>{if(o&&typeof o=="object"||typeof o=="function")for(let u of M(o))!B.call(t,u)&&u!==l&&O(t,u,{get:()=>o[u],enumerable:!(p=J(o,u))||p.enumerable});return t};var U=(t,o,l)=>(l=t!=null?I(z(t)):{},j(o||!t||!t.__esModule?O(l,"default",{value:t,enumerable:!0}):l,t));Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function x(t){return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}${t}`}function g(t,o){if(!t.parent)return"";const l=o.get(t.parent);return l?l.name:""}function F(t){const o=t?.url??x(t?.path??"/__twd/ws"),l=t?.reconnect??!0,p=t?.reconnectInterval??2e3,u=t?.log??!1,C="[twd-relay]";function w(...n){u&&console.info(C,...n)}function N(...n){console.warn(C,...n)}let r=null,h=!1,S=null;function s(n){r&&r.readyState===WebSocket.OPEN&&r.send(JSON.stringify(n))}function f(){window.dispatchEvent(new CustomEvent("twd:state-change"))}async function R(n){const i=window.__TWD_STATE__;if(!i){N("TWD not initialized — make sure twd-js is loaded before running tests"),s({type:"error",code:"NO_TWD",message:"TWD not initialized"});return}const d=i.handlers;let c;if(n&&n.length>0){const e=n.map(m=>m.toLowerCase()),a=[];for(const[,m]of d)if(m.type==="test"){const y=m.name.toLowerCase();e.some(P=>y.includes(P))&&a.push(m.id)}if(a.length===0){const m=Array.from(d.values()).filter(y=>y.type==="test").map(y=>y.name);s({type:"run:start",testCount:0}),s({type:"error",code:"NO_MATCH",message:`No tests matched: ${JSON.stringify(n)}. Available tests: ${JSON.stringify(m)}`}),s({type:"run:complete",passed:0,failed:0,skipped:0,duration:0});return}c=a}s({type:"run:start",testCount:c?c.length:Array.from(d.values()).filter(e=>e.type==="test").length});let _=0,b=0,v=0;const T=performance.now(),L={onStart(e){e.status="running",f(),s({type:"test:start",id:e.id,name:e.name,suite:g(e,d)})},onPass(e){_++,e.status="pass",f(),s({type:"test:pass",id:e.id,name:e.name,suite:g(e,d),duration:performance.now()-T})},onFail(e,a){b++,e.status="fail",e.logs=[a.message],f(),s({type:"test:fail",id:e.id,name:e.name,suite:g(e,d),error:a.message,duration:performance.now()-T})},onSkip(e){v++,e.status="skip",f(),s({type:"test:skip",id:e.id,name:e.name,suite:g(e,d)})},onSuiteStart(e){e.status="running",f()},onSuiteEnd(e){e.status="idle",f()}};try{const{TestRunner:e}=await import("twd-js/runner"),a=new e(L);c?await a.runByIds(c):await a.runAll()}catch(e){const a=e instanceof Error?e.message:String(e);N("Runner error:",a),s({type:"error",code:"RUNNER_ERROR",message:a})}const $=performance.now()-T;s({type:"run:complete",passed:_,failed:b,skipped:v,duration:$}),f()}function W(){const n=window.__TWD_STATE__;if(!n){s({type:"error",code:"NO_TWD",message:"TWD not initialized"});return}const i=n.handlers,d=[];for(const[,c]of i)c.type==="test"&&d.push({id:c.id,name:c.name,suite:g(c,i),status:c.status??"idle"});s({type:"status:result",tests:d})}function k(n){let i;try{i=JSON.parse(n.data)}catch{return}i.type==="run"?(w("Received run command — running tests..."),R(Array.isArray(i.testNames)?i.testNames:void 0)):i.type==="status"&&W()}function A(){l&&!h&&(w(`Reconnecting in ${p}ms...`),S=setTimeout(()=>{E()},p))}function E(){r&&(r.readyState===WebSocket.OPEN||r.readyState===WebSocket.CONNECTING)||(h=!1,w("Connecting to",o),r=new WebSocket(o),r.addEventListener("open",()=>{s({type:"hello",role:"browser"}),w("Connected to relay — ready to receive run/status commands")}),r.addEventListener("message",k),r.addEventListener("close",n=>{if(r=null,n.reason==="Replaced by new browser"){N("Another browser instance connected — this instance will not reconnect");return}h||w("Disconnected",n.code?`(code ${n.code})`:"",n.reason||""),A()}),r.addEventListener("error",()=>{}))}function D(){h=!0,S&&(clearTimeout(S),S=null),r&&(r.close(1e3,"Client disconnecting"),r=null)}return{connect:E,disconnect:D,get connected(){return r!==null&&r.readyState===WebSocket.OPEN}}}exports.createBrowserClient=F;
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,194 +1,312 @@
1
- function I(i) {
2
- return `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}${i}`;
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 m(i, p) {
5
- if (!i.parent) return "";
6
- const w = p.get(i.parent);
7
- return w ? w.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 P(i) {
10
- const p = i?.url ?? I(i?.path ?? "/__twd/ws"), w = i?.reconnect ?? !0, N = i?.reconnectInterval ?? 2e3, v = i?.log ?? !1, T = "[twd-relay]";
11
- function u(...t) {
12
- v && console.info(T, ...t);
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 h(...t) {
15
- console.warn(T, ...t);
84
+ function _(...r) {
85
+ console.warn(s, ...r);
16
86
  }
17
- let n = null, y = !1, g = null;
18
- function r(t) {
19
- n && n.readyState === WebSocket.OPEN && n.send(JSON.stringify(t));
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 l() {
92
+ function g() {
22
93
  window.dispatchEvent(new CustomEvent("twd:state-change"));
23
94
  }
24
- async function O(t) {
25
- const o = window.__TWD_STATE__;
26
- if (!o) {
27
- h("TWD not initialized — make sure twd-js is loaded before running tests"), r({
28
- type: "error",
29
- code: "NO_TWD",
30
- message: "TWD not initialized"
31
- });
32
- 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));
33
113
  }
34
- const c = o.handlers;
35
- let a;
36
- if (t && t.length > 0) {
37
- const e = t.map((d) => d.toLowerCase()), s = [];
38
- for (const [, d] of c) if (d.type === "test") {
39
- const f = d.name.toLowerCase();
40
- e.some(($) => f.includes($)) && s.push(d.id);
41
- }
42
- if (s.length === 0) {
43
- const d = Array.from(c.values()).filter((f) => f.type === "test").map((f) => f.name);
44
- r({
45
- type: "run:start",
46
- testCount: 0
47
- }), 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({
48
123
  type: "error",
49
- code: "NO_MATCH",
50
- message: `No tests matched: ${JSON.stringify(t)}. Available tests: ${JSON.stringify(d)}`
51
- }), r({
52
- type: "run:complete",
53
- passed: 0,
54
- failed: 0,
55
- skipped: 0,
56
- duration: 0
124
+ code: "NO_TWD",
125
+ message: "TWD not initialized"
57
126
  });
58
127
  return;
59
128
  }
60
- a = s;
61
- }
62
- r({
63
- type: "run:start",
64
- testCount: a ? a.length : Array.from(c.values()).filter((e) => e.type === "test").length
65
- });
66
- let E = 0, _ = 0, b = 0;
67
- const S = performance.now(), D = {
68
- onStart(e) {
69
- e.status = "running", l(), r({
70
- type: "test:start",
71
- id: e.id,
72
- name: e.name,
73
- suite: m(e, c)
74
- });
75
- },
76
- onPass(e) {
77
- E++, e.status = "pass", l(), r({
78
- type: "test:pass",
79
- id: e.id,
80
- name: e.name,
81
- suite: m(e, c),
82
- duration: performance.now() - S
83
- });
84
- },
85
- onFail(e, s) {
86
- _++, e.status = "fail", e.logs = [s.message], l(), r({
87
- type: "test:fail",
88
- id: e.id,
89
- name: e.name,
90
- suite: m(e, c),
91
- error: s.message,
92
- duration: performance.now() - S
93
- });
94
- },
95
- onSkip(e) {
96
- b++, e.status = "skip", l(), r({
97
- type: "test:skip",
98
- id: e.id,
99
- name: e.name,
100
- suite: m(e, c)
101
- });
102
- },
103
- onSuiteStart(e) {
104
- e.status = "running", l();
105
- },
106
- onSuiteEnd(e) {
107
- e.status = "idle", l();
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;
108
156
  }
109
- };
110
- try {
111
- const { TestRunner: e } = await import("twd-js/runner"), s = new e(D);
112
- a ? await s.runByIds(a) : await s.runAll();
113
- } catch (e) {
114
- const s = e instanceof Error ? e.message : String(e);
115
- h("Runner error:", s), r({
116
- type: "error",
117
- code: "RUNNER_ERROR",
118
- message: s
157
+ l({
158
+ type: "run:start",
159
+ testCount: v ? v.length : Array.from(T.values()).filter((e) => e.type === "test").length
119
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);
120
246
  }
121
- const L = performance.now() - S;
122
- r({
123
- type: "run:complete",
124
- passed: E,
125
- failed: _,
126
- skipped: b,
127
- duration: L
128
- }), l();
129
247
  }
130
- function R() {
131
- const t = window.__TWD_STATE__;
132
- if (!t) {
133
- r({
248
+ function O() {
249
+ const r = window.__TWD_STATE__;
250
+ if (!r) {
251
+ l({
134
252
  type: "error",
135
253
  code: "NO_TWD",
136
254
  message: "TWD not initialized"
137
255
  });
138
256
  return;
139
257
  }
140
- const o = t.handlers, c = [];
141
- for (const [, a] of o) a.type === "test" && c.push({
142
- id: a.id,
143
- name: a.name,
144
- suite: m(a, o),
145
- status: a.status ?? "idle"
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"
146
264
  });
147
- r({
265
+ l({
148
266
  type: "status:result",
149
- tests: c
267
+ tests: o
150
268
  });
151
269
  }
152
- function W(t) {
153
- let o;
270
+ function L(r) {
271
+ let m;
154
272
  try {
155
- o = JSON.parse(t.data);
273
+ m = JSON.parse(r.data);
156
274
  } catch {
157
275
  return;
158
276
  }
159
- o.type === "run" ? (u("Received run command — running tests..."), O(Array.isArray(o.testNames) ? o.testNames : void 0)) : o.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();
160
278
  }
161
- function k() {
162
- w && !y && (u(`Reconnecting in ${N}ms...`), g = setTimeout(() => {
163
- C();
164
- }, N));
279
+ function $() {
280
+ c && !k && (d(`Reconnecting in ${u}ms...`), N = setTimeout(() => {
281
+ R();
282
+ }, u));
165
283
  }
166
- function C() {
167
- n && (n.readyState === WebSocket.OPEN || n.readyState === WebSocket.CONNECTING) || (y = !1, u("Connecting to", p), n = new WebSocket(p), n.addEventListener("open", () => {
168
- r({
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({
169
287
  type: "hello",
170
288
  role: "browser"
171
- }), u("Connected to relay — ready to receive run/status commands");
172
- }), n.addEventListener("message", W), n.addEventListener("close", (t) => {
173
- if (n = null, t.reason === "Replaced by new browser") {
174
- h("Another browser instance connected — this instance will not reconnect");
289
+ }), w.save(), w.set("connected"), d("Connected to relay — ready 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");
175
293
  return;
176
294
  }
177
- y || u("Disconnected", t.code ? `(code ${t.code})` : "", t.reason || ""), k();
178
- }), n.addEventListener("error", () => {
295
+ k || d("Disconnected", r.code ? `(code ${r.code})` : "", r.reason || ""), $();
296
+ }), a.addEventListener("error", () => {
179
297
  }));
180
298
  }
181
- function A() {
182
- y = !0, g && (clearTimeout(g), g = null), n && (n.close(1e3, "Client disconnecting"), n = null);
299
+ function P() {
300
+ k = !0, N && (clearTimeout(N), N = null), a && (a.close(1e3, "Client disconnecting"), a = null);
183
301
  }
184
302
  return {
185
- connect: C,
186
- disconnect: A,
303
+ connect: R,
304
+ disconnect: P,
187
305
  get connected() {
188
- return n !== null && n.readyState === WebSocket.OPEN;
306
+ return a !== null && a.readyState === WebSocket.OPEN;
189
307
  }
190
308
  };
191
309
  }
192
310
  export {
193
- P as createBrowserClient
311
+ G as createBrowserClient
194
312
  };
package/dist/cli.js CHANGED
@@ -10,6 +10,10 @@ function createTwdRelay(server, options) {
10
10
  let browser = null;
11
11
  const clients = /* @__PURE__ */ new Set();
12
12
  let runInProgress = false;
13
+ let lastHeartbeat = null;
14
+ let heartbeatCheckTimer = null;
15
+ const HEARTBEAT_TIMEOUT_MS = 12e4;
16
+ const HEARTBEAT_CHECK_INTERVAL_MS = 1e4;
13
17
  function sendError(ws, code, message) {
14
18
  const msg = {
15
19
  type: "error",
@@ -34,6 +38,28 @@ function createTwdRelay(server, options) {
34
38
  browser: false
35
39
  }));
36
40
  }
41
+ function startHeartbeatTracking() {
42
+ lastHeartbeat = Date.now();
43
+ heartbeatCheckTimer = setInterval(() => {
44
+ if (runInProgress && lastHeartbeat !== null) {
45
+ if (Date.now() - lastHeartbeat > HEARTBEAT_TIMEOUT_MS) {
46
+ runInProgress = false;
47
+ stopHeartbeatTracking();
48
+ broadcastToClients(JSON.stringify({
49
+ type: "run:abandoned",
50
+ reason: "heartbeat_timeout"
51
+ }));
52
+ }
53
+ }
54
+ }, HEARTBEAT_CHECK_INTERVAL_MS);
55
+ }
56
+ function stopHeartbeatTracking() {
57
+ lastHeartbeat = null;
58
+ if (heartbeatCheckTimer !== null) {
59
+ clearInterval(heartbeatCheckTimer);
60
+ heartbeatCheckTimer = null;
61
+ }
62
+ }
37
63
  function handleBrowserMessage(data) {
38
64
  let parsed;
39
65
  try {
@@ -41,7 +67,14 @@ function createTwdRelay(server, options) {
41
67
  } catch {
42
68
  return;
43
69
  }
44
- if (parsed.type === "run:complete") runInProgress = false;
70
+ if (parsed.type === "heartbeat") {
71
+ lastHeartbeat = Date.now();
72
+ return;
73
+ }
74
+ if (parsed.type === "run:complete") {
75
+ runInProgress = false;
76
+ stopHeartbeatTracking();
77
+ }
45
78
  broadcastToClients(data);
46
79
  }
47
80
  function handleClientMessage(ws, data) {
@@ -62,10 +95,11 @@ function createTwdRelay(server, options) {
62
95
  return;
63
96
  }
64
97
  if (runInProgress) {
65
- sendError(ws, "RUN_IN_PROGRESS", "A test run is already in progress");
98
+ sendError(ws, "RUN_IN_PROGRESS", "A test run is already in progress. If the previous run appears stuck, the browser tab may be backgrounded and throttled — foreground the TWD tab (identified by the \"[TWD …]\" title prefix) or reload it. The relay also auto-clears the lock after 120s of heartbeat silence.");
66
99
  return;
67
100
  }
68
101
  runInProgress = true;
102
+ startHeartbeatTracking();
69
103
  browser.send(data);
70
104
  return;
71
105
  }
@@ -100,10 +134,12 @@ function createTwdRelay(server, options) {
100
134
  if (browser && browser.readyState === WebSocket$1.OPEN) browser.close(1e3, "Replaced by new browser");
101
135
  browser = ws;
102
136
  runInProgress = false;
137
+ stopHeartbeatTracking();
103
138
  ws.on("close", () => {
104
139
  if (browser === ws) {
105
140
  browser = null;
106
141
  runInProgress = false;
142
+ stopHeartbeatTracking();
107
143
  notifyBrowserDisconnected();
108
144
  }
109
145
  });
@@ -144,6 +180,7 @@ function createTwdRelay(server, options) {
144
180
  return {
145
181
  close() {
146
182
  server.removeListener("upgrade", upgradeHandler);
183
+ stopHeartbeatTracking();
147
184
  for (const client of clients) client.close(1e3, "Relay shutting down");
148
185
  clients.clear();
149
186
  if (browser && browser.readyState === WebSocket$1.OPEN) browser.close(1e3, "Relay shutting down");
@@ -162,7 +199,7 @@ function createTwdRelay(server, options) {
162
199
  //#endregion
163
200
  //#region src/cli/run.ts
164
201
  function run(options) {
165
- const { port, timeout, path, host, testNames } = options;
202
+ const { port, timeout, path, host, testNames, maxTestDurationMs } = options;
166
203
  const url = `ws://${host}:${port}${path}`;
167
204
  console.log(`Connecting to ${url}...`);
168
205
  const ws = new WebSocket(url);
@@ -192,6 +229,7 @@ function run(options) {
192
229
  scope: "all"
193
230
  };
194
231
  if (testNames?.length) runMsg.testNames = testNames;
232
+ if (maxTestDurationMs !== void 0) runMsg.maxTestDurationMs = maxTestDurationMs;
195
233
  ws.send(JSON.stringify(runMsg));
196
234
  } else if (!msg.browser) console.log("Waiting for browser to connect...");
197
235
  break;
@@ -223,6 +261,18 @@ function run(options) {
223
261
  process.exit(failed || msg.failed > 0 ? 1 : 0);
224
262
  break;
225
263
  }
264
+ case "run:aborted": {
265
+ failed = true;
266
+ const seconds = typeof msg.durationMs === "number" ? (msg.durationMs / 1e3).toFixed(1) : "?";
267
+ console.error(`\nRun aborted: test "${msg.testName ?? "?"}" ran for ${seconds}s — threshold exceeded.\nThe TWD browser tab is likely backgrounded and throttled by the browser.\nForeground the TWD tab (identified by the "[TWD …]" title prefix) and keep it active, then retry.\nFor unattended runs, prefer \`twd-cli\` which drives a headless browser with no tab throttling.`);
268
+ break;
269
+ }
270
+ case "run:abandoned":
271
+ console.error("\nRun abandoned — browser tab appears frozen. Refresh the browser tab and retry.");
272
+ clearTimeout(timer);
273
+ ws.close();
274
+ process.exit(1);
275
+ break;
226
276
  case "error":
227
277
  console.error(`Error [${msg.code}]: ${msg.message}`);
228
278
  break;
@@ -266,11 +316,14 @@ Options for serve:
266
316
  --path <path> WebSocket path (default: /__twd/ws)
267
317
 
268
318
  Options for run:
269
- --port <port> Relay port to connect to (default: 5173)
270
- --host <host> Relay host to connect to (default: localhost)
271
- --path <path> WebSocket path (default: /__twd/ws)
272
- --timeout <ms> Timeout in ms (default: 180000)
273
- --test <name> Filter tests by name substring (repeatable)
319
+ --port <port> Relay port to connect to (default: 5173)
320
+ --host <host> Relay host to connect to (default: localhost)
321
+ --path <path> WebSocket path (default: /__twd/ws)
322
+ --timeout <ms> Timeout in ms (default: 180000)
323
+ --test <name> Filter tests by name substring (repeatable)
324
+ --max-test-duration <ms> Abort if any single test exceeds this many
325
+ ms (default from browser client, typically
326
+ 5000; 0 disables)
274
327
 
275
328
  Examples:
276
329
  twd-relay # start relay on port 9876
@@ -280,7 +333,9 @@ Examples:
280
333
  twd-relay run --host 192.168.1.10 --path /app/__twd/ws
281
334
  twd-relay run --timeout 30000 # custom timeout
282
335
  twd-relay run --test "login" # run tests matching "login"
283
- twd-relay run --test "login" --test "signup" # run multiple`);
336
+ twd-relay run --test "login" --test "signup" # run multiple
337
+ twd-relay run --max-test-duration 30000 # raise abort threshold to 30s
338
+ twd-relay run --max-test-duration 0 # disable abort detection`);
284
339
  }
285
340
  if (args.includes("--help") || args.includes("-h")) {
286
341
  printHelp();
@@ -302,12 +357,22 @@ if (subcommand === "run") {
302
357
  process.exit(1);
303
358
  }
304
359
  const testNames = parseFlagAll("--test");
360
+ const maxDurationStr = parseFlag("--max-test-duration");
361
+ let maxTestDurationMs;
362
+ if (maxDurationStr !== void 0) {
363
+ maxTestDurationMs = parseInt(maxDurationStr, 10);
364
+ if (isNaN(maxTestDurationMs)) {
365
+ console.error("Invalid --max-test-duration value:", maxDurationStr);
366
+ process.exit(1);
367
+ }
368
+ }
305
369
  run({
306
370
  port,
307
371
  timeout,
308
372
  path: pathFlag,
309
373
  host: hostFlag,
310
- testNames: testNames.length > 0 ? testNames : void 0
374
+ testNames: testNames.length > 0 ? testNames : void 0,
375
+ maxTestDurationMs
311
376
  });
312
377
  } else if (!subcommand || subcommand === "serve") {
313
378
  const portStr = parseFlag("--port");
package/dist/index.cjs.js CHANGED
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./relay-CpUORBd7.cjs");exports.createTwdRelay=e.createTwdRelay;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./relay-CK82tv0w.cjs");exports.createTwdRelay=e.createTwdRelay;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Server } from 'http';
2
2
 
3
- export declare type BrowserEvent = ConnectedEvent | RunStartEvent | TestStartEvent | TestPassEvent | TestFailEvent | TestSkipEvent | RunCompleteEvent;
3
+ export declare type BrowserEvent = ConnectedEvent | RunStartEvent | TestStartEvent | TestPassEvent | TestFailEvent | TestSkipEvent | RunCompleteEvent | RunAbandonedEvent | RunAbortedEvent;
4
4
 
5
5
  export declare type Command = RunCommand | StatusCommand;
6
6
 
@@ -11,6 +11,10 @@ export declare interface ConnectedEvent {
11
11
 
12
12
  export declare function createTwdRelay(server: Server, options?: TwdRelayOptions): TwdRelay;
13
13
 
14
+ declare interface HeartbeatMessage {
15
+ type: 'heartbeat';
16
+ }
17
+
14
18
  export declare interface HelloBrowserMessage {
15
19
  type: 'hello';
16
20
  role: 'browser';
@@ -23,12 +27,28 @@ export declare interface HelloClientMessage {
23
27
 
24
28
  export declare type HelloMessage = HelloBrowserMessage | HelloClientMessage;
25
29
 
26
- export declare type InboundMessage = HelloMessage | Command | BrowserEvent;
30
+ export declare type InboundMessage = HelloMessage | Command | HeartbeatMessage | BrowserEvent;
31
+
32
+ declare interface RunAbandonedEvent {
33
+ type: 'run:abandoned';
34
+ reason: 'heartbeat_timeout';
35
+ }
36
+
37
+ declare interface RunAbortedEvent {
38
+ type: 'run:aborted';
39
+ reason: 'throttled';
40
+ durationMs: number;
41
+ testName: string;
42
+ }
27
43
 
28
44
  export declare interface RunCommand {
29
45
  type: 'run';
30
46
  scope: 'all';
31
47
  testNames?: string[];
48
+ /** Max wall-clock ms any single test may run before the browser aborts
49
+ * the run with reason 'throttled'. 0 disables detection. Omit to let
50
+ * the browser use its own default (5000). */
51
+ maxTestDurationMs?: number;
32
52
  }
33
53
 
34
54
  export declare interface RunCompleteEvent {
package/dist/index.es.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as r } from "./relay-C8UTSEbG.js";
1
+ import { t as r } from "./relay-NDt-wlXz.js";
2
2
  export {
3
3
  r as createTwdRelay
4
4
  };
@@ -0,0 +1 @@
1
+ let o=require("ws");var P="/__twd/ws";function D(g,N){const E=N?.path??P,S=N?.onError,p=new o.WebSocketServer({noServer:!0});let t=null;const c=new Set;let l=!1,d=null,y=null;const O=12e4,m=1e4;function s(e,r,n){const a={type:"error",code:r,message:n};e.readyState===o.WebSocket.OPEN&&e.send(JSON.stringify(a))}function b(e){for(const r of c)r.readyState===o.WebSocket.OPEN&&r.send(e)}function T(e){const r={type:"connected",browser:t!==null&&t.readyState===o.WebSocket.OPEN};e.readyState===o.WebSocket.OPEN&&e.send(JSON.stringify(r))}function _(){b(JSON.stringify({type:"connected",browser:!1}))}function k(){d=Date.now(),y=setInterval(()=>{l&&d!==null&&Date.now()-d>O&&(l=!1,f(),b(JSON.stringify({type:"run:abandoned",reason:"heartbeat_timeout"})))},m)}function f(){d=null,y!==null&&(clearInterval(y),y=null)}function A(e){let r;try{r=JSON.parse(e)}catch{return}if(r.type==="heartbeat"){d=Date.now();return}r.type==="run:complete"&&(l=!1,f()),b(e)}function I(e,r){let n;try{n=JSON.parse(r)}catch{s(e,"INVALID_MESSAGE","Invalid JSON");return}if(!n.type){s(e,"INVALID_MESSAGE",'Missing "type" field');return}if(n.type==="run"){if(!t||t.readyState!==o.WebSocket.OPEN){s(e,"NO_BROWSER","No browser connected");return}if(l){s(e,"RUN_IN_PROGRESS",'A test run is already in progress. If the previous run appears stuck, the browser tab may be backgrounded and throttled — foreground the TWD tab (identified by the "[TWD …]" title prefix) or reload it. The relay also auto-clears the lock after 120s of heartbeat silence.');return}l=!0,k(),t.send(r);return}if(n.type==="status"){if(!t||t.readyState!==o.WebSocket.OPEN){s(e,"NO_BROWSER","No browser connected");return}t.send(r);return}s(e,"UNKNOWN_COMMAND",`Unknown command: ${n.type}`)}function R(e){let r=!1;const n=a=>{const W=typeof a=="string"?a:a.toString();if(!r){let u;try{u=JSON.parse(W)}catch{s(e,"INVALID_MESSAGE","Invalid JSON");return}if(u.type!=="hello"||u.role!=="browser"&&u.role!=="client"){s(e,"INVALID_MESSAGE",'First message must be a hello with role "browser" or "client"');return}r=!0,u.role==="browser"?(t&&t.readyState===o.WebSocket.OPEN&&t.close(1e3,"Replaced by new browser"),t=e,l=!1,f(),e.on("close",()=>{t===e&&(t=null,l=!1,f(),_())}),b(JSON.stringify({type:"connected",browser:!0})),e.on("message",i=>{A(typeof i=="string"?i:i.toString())})):(c.add(e),e.on("close",()=>{c.delete(e)}),T(e),e.on("message",i=>{I(e,typeof i=="string"?i:i.toString())})),e.removeListener("message",n)}};e.on("message",n),e.on("error",a=>{S&&S(a)})}const h=(e,r,n)=>{new URL(e.url??"/",`http://${e.headers.host??"localhost"}`).pathname===E&&p.handleUpgrade(e,r,n,a=>{R(a)})};return g.on("upgrade",h),p.on("error",e=>{S&&S(e)}),{close(){g.removeListener("upgrade",h),f();for(const e of c)e.close(1e3,"Relay shutting down");c.clear(),t&&t.readyState===o.WebSocket.OPEN&&t.close(1e3,"Relay shutting down"),t=null,l=!1,p.close()},get browserConnected(){return t!==null&&t.readyState===o.WebSocket.OPEN},get clientCount(){return c.size}}}Object.defineProperty(exports,"createTwdRelay",{enumerable:!0,get:function(){return D}});
@@ -0,0 +1,148 @@
1
+ import { WebSocket as a, WebSocketServer as P } from "ws";
2
+ var C = "/__twd/ws";
3
+ function k(N, h) {
4
+ const b = h?.path ?? C, y = h?.onError, g = new P({ noServer: !0 });
5
+ let t = null;
6
+ const c = /* @__PURE__ */ new Set();
7
+ let s = !1, f = null, S = null;
8
+ const O = 12e4, m = 1e4;
9
+ function l(e, r, n) {
10
+ const o = {
11
+ type: "error",
12
+ code: r,
13
+ message: n
14
+ };
15
+ e.readyState === a.OPEN && e.send(JSON.stringify(o));
16
+ }
17
+ function p(e) {
18
+ for (const r of c) r.readyState === a.OPEN && r.send(e);
19
+ }
20
+ function _(e) {
21
+ const r = {
22
+ type: "connected",
23
+ browser: t !== null && t.readyState === a.OPEN
24
+ };
25
+ e.readyState === a.OPEN && e.send(JSON.stringify(r));
26
+ }
27
+ function A() {
28
+ p(JSON.stringify({
29
+ type: "connected",
30
+ browser: !1
31
+ }));
32
+ }
33
+ function I() {
34
+ f = Date.now(), S = setInterval(() => {
35
+ s && f !== null && Date.now() - f > O && (s = !1, d(), p(JSON.stringify({
36
+ type: "run:abandoned",
37
+ reason: "heartbeat_timeout"
38
+ })));
39
+ }, m);
40
+ }
41
+ function d() {
42
+ f = null, S !== null && (clearInterval(S), S = null);
43
+ }
44
+ function T(e) {
45
+ let r;
46
+ try {
47
+ r = JSON.parse(e);
48
+ } catch {
49
+ return;
50
+ }
51
+ if (r.type === "heartbeat") {
52
+ f = Date.now();
53
+ return;
54
+ }
55
+ r.type === "run:complete" && (s = !1, d()), p(e);
56
+ }
57
+ function R(e, r) {
58
+ let n;
59
+ try {
60
+ n = JSON.parse(r);
61
+ } catch {
62
+ l(e, "INVALID_MESSAGE", "Invalid JSON");
63
+ return;
64
+ }
65
+ if (!n.type) {
66
+ l(e, "INVALID_MESSAGE", 'Missing "type" field');
67
+ return;
68
+ }
69
+ if (n.type === "run") {
70
+ if (!t || t.readyState !== a.OPEN) {
71
+ l(e, "NO_BROWSER", "No browser connected");
72
+ return;
73
+ }
74
+ if (s) {
75
+ l(e, "RUN_IN_PROGRESS", 'A test run is already in progress. If the previous run appears stuck, the browser tab may be backgrounded and throttled — foreground the TWD tab (identified by the "[TWD …]" title prefix) or reload it. The relay also auto-clears the lock after 120s of heartbeat silence.');
76
+ return;
77
+ }
78
+ s = !0, I(), t.send(r);
79
+ return;
80
+ }
81
+ if (n.type === "status") {
82
+ if (!t || t.readyState !== a.OPEN) {
83
+ l(e, "NO_BROWSER", "No browser connected");
84
+ return;
85
+ }
86
+ t.send(r);
87
+ return;
88
+ }
89
+ l(e, "UNKNOWN_COMMAND", `Unknown command: ${n.type}`);
90
+ }
91
+ function D(e) {
92
+ let r = !1;
93
+ const n = (o) => {
94
+ const M = typeof o == "string" ? o : o.toString();
95
+ if (!r) {
96
+ let u;
97
+ try {
98
+ u = JSON.parse(M);
99
+ } catch {
100
+ l(e, "INVALID_MESSAGE", "Invalid JSON");
101
+ return;
102
+ }
103
+ if (u.type !== "hello" || u.role !== "browser" && u.role !== "client") {
104
+ l(e, "INVALID_MESSAGE", 'First message must be a hello with role "browser" or "client"');
105
+ return;
106
+ }
107
+ r = !0, u.role === "browser" ? (t && t.readyState === a.OPEN && t.close(1e3, "Replaced by new browser"), t = e, s = !1, d(), e.on("close", () => {
108
+ t === e && (t = null, s = !1, d(), A());
109
+ }), p(JSON.stringify({
110
+ type: "connected",
111
+ browser: !0
112
+ })), e.on("message", (i) => {
113
+ T(typeof i == "string" ? i : i.toString());
114
+ })) : (c.add(e), e.on("close", () => {
115
+ c.delete(e);
116
+ }), _(e), e.on("message", (i) => {
117
+ R(e, typeof i == "string" ? i : i.toString());
118
+ })), e.removeListener("message", n);
119
+ }
120
+ };
121
+ e.on("message", n), e.on("error", (o) => {
122
+ y && y(o);
123
+ });
124
+ }
125
+ const E = (e, r, n) => {
126
+ new URL(e.url ?? "/", `http://${e.headers.host ?? "localhost"}`).pathname === b && g.handleUpgrade(e, r, n, (o) => {
127
+ D(o);
128
+ });
129
+ };
130
+ return N.on("upgrade", E), g.on("error", (e) => {
131
+ y && y(e);
132
+ }), {
133
+ close() {
134
+ N.removeListener("upgrade", E), d();
135
+ for (const e of c) e.close(1e3, "Relay shutting down");
136
+ c.clear(), t && t.readyState === a.OPEN && t.close(1e3, "Relay shutting down"), t = null, s = !1, g.close();
137
+ },
138
+ get browserConnected() {
139
+ return t !== null && t.readyState === a.OPEN;
140
+ },
141
+ get clientCount() {
142
+ return c.size;
143
+ }
144
+ };
145
+ }
146
+ export {
147
+ k as t
148
+ };
package/dist/vite.cjs.js CHANGED
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const l=require("./relay-CpUORBd7.cjs");function n(r){let t="/";return{name:"twd-relay",configResolved(e){t=e.base},configureServer(e){if(!e.httpServer)return;const o=r?.path??t.replace(/\/$/,"")+"/__twd/ws",a=l.createTwdRelay(e.httpServer,{path:o});e.httpServer.on("close",()=>a.close())}}}exports.twdRemote=n;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const l=require("./relay-CK82tv0w.cjs");function n(r){let t="/";return{name:"twd-relay",configResolved(e){t=e.base},configureServer(e){if(!e.httpServer)return;const o=r?.path??t.replace(/\/$/,"")+"/__twd/ws",a=l.createTwdRelay(e.httpServer,{path:o});e.httpServer.on("close",()=>a.close())}}}exports.twdRemote=n;
package/dist/vite.es.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as n } from "./relay-C8UTSEbG.js";
1
+ import { t as n } from "./relay-NDt-wlXz.js";
2
2
  function l(r) {
3
3
  let t = "/";
4
4
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twd-relay",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "WebSocket relay for TWD — enables AI agents and external tools to run and observe in-browser tests",
5
5
  "license": "MIT",
6
6
  "author": "BRIKEV",
@@ -66,6 +66,7 @@
66
66
  "@types/ws": "^8.18.1",
67
67
  "@vitest/coverage-v8": "^4.1.0",
68
68
  "esbuild": "^0.27.4",
69
+ "happy-dom": "^15.11.7",
69
70
  "typescript": "^5.9.3",
70
71
  "vite": "^8.0.1",
71
72
  "vite-plugin-dts": "^4.5.4",
@@ -1,132 +0,0 @@
1
- import { WebSocket as s, WebSocketServer as I } from "ws";
2
- var P = "/__twd/ws";
3
- function _(y, p) {
4
- const g = p?.path ?? P, f = p?.onError, u = new I({ noServer: !0 });
5
- let t = null;
6
- const a = /* @__PURE__ */ new Set();
7
- let c = !1;
8
- function i(e, n, r) {
9
- const o = {
10
- type: "error",
11
- code: n,
12
- message: r
13
- };
14
- e.readyState === s.OPEN && e.send(JSON.stringify(o));
15
- }
16
- function S(e) {
17
- for (const n of a) n.readyState === s.OPEN && n.send(e);
18
- }
19
- function O(e) {
20
- const n = {
21
- type: "connected",
22
- browser: t !== null && t.readyState === s.OPEN
23
- };
24
- e.readyState === s.OPEN && e.send(JSON.stringify(n));
25
- }
26
- function E() {
27
- S(JSON.stringify({
28
- type: "connected",
29
- browser: !1
30
- }));
31
- }
32
- function h(e) {
33
- let n;
34
- try {
35
- n = JSON.parse(e);
36
- } catch {
37
- return;
38
- }
39
- n.type === "run:complete" && (c = !1), S(e);
40
- }
41
- function b(e, n) {
42
- let r;
43
- try {
44
- r = JSON.parse(n);
45
- } catch {
46
- i(e, "INVALID_MESSAGE", "Invalid JSON");
47
- return;
48
- }
49
- if (!r.type) {
50
- i(e, "INVALID_MESSAGE", 'Missing "type" field');
51
- return;
52
- }
53
- if (r.type === "run") {
54
- if (!t || t.readyState !== s.OPEN) {
55
- i(e, "NO_BROWSER", "No browser connected");
56
- return;
57
- }
58
- if (c) {
59
- i(e, "RUN_IN_PROGRESS", "A test run is already in progress");
60
- return;
61
- }
62
- c = !0, t.send(n);
63
- return;
64
- }
65
- if (r.type === "status") {
66
- if (!t || t.readyState !== s.OPEN) {
67
- i(e, "NO_BROWSER", "No browser connected");
68
- return;
69
- }
70
- t.send(n);
71
- return;
72
- }
73
- i(e, "UNKNOWN_COMMAND", `Unknown command: ${r.type}`);
74
- }
75
- function m(e) {
76
- let n = !1;
77
- const r = (o) => {
78
- const A = typeof o == "string" ? o : o.toString();
79
- if (!n) {
80
- let d;
81
- try {
82
- d = JSON.parse(A);
83
- } catch {
84
- i(e, "INVALID_MESSAGE", "Invalid JSON");
85
- return;
86
- }
87
- if (d.type !== "hello" || d.role !== "browser" && d.role !== "client") {
88
- i(e, "INVALID_MESSAGE", 'First message must be a hello with role "browser" or "client"');
89
- return;
90
- }
91
- n = !0, d.role === "browser" ? (t && t.readyState === s.OPEN && t.close(1e3, "Replaced by new browser"), t = e, c = !1, e.on("close", () => {
92
- t === e && (t = null, c = !1, E());
93
- }), S(JSON.stringify({
94
- type: "connected",
95
- browser: !0
96
- })), e.on("message", (l) => {
97
- h(typeof l == "string" ? l : l.toString());
98
- })) : (a.add(e), e.on("close", () => {
99
- a.delete(e);
100
- }), O(e), e.on("message", (l) => {
101
- b(e, typeof l == "string" ? l : l.toString());
102
- })), e.removeListener("message", r);
103
- }
104
- };
105
- e.on("message", r), e.on("error", (o) => {
106
- f && f(o);
107
- });
108
- }
109
- const N = (e, n, r) => {
110
- new URL(e.url ?? "/", `http://${e.headers.host ?? "localhost"}`).pathname === g && u.handleUpgrade(e, n, r, (o) => {
111
- m(o);
112
- });
113
- };
114
- return y.on("upgrade", N), u.on("error", (e) => {
115
- f && f(e);
116
- }), {
117
- close() {
118
- y.removeListener("upgrade", N);
119
- for (const e of a) e.close(1e3, "Relay shutting down");
120
- a.clear(), t && t.readyState === s.OPEN && t.close(1e3, "Relay shutting down"), t = null, c = !1, u.close();
121
- },
122
- get browserConnected() {
123
- return t !== null && t.readyState === s.OPEN;
124
- },
125
- get clientCount() {
126
- return a.size;
127
- }
128
- };
129
- }
130
- export {
131
- _ as t
132
- };
@@ -1 +0,0 @@
1
- let o=require("ws");var R="/__twd/ws";function W(y,N){const p=N?.path??R,f=N?.onError,u=new o.WebSocketServer({noServer:!0});let t=null;const l=new Set;let a=!1;function c(e,n,r){const s={type:"error",code:n,message:r};e.readyState===o.WebSocket.OPEN&&e.send(JSON.stringify(s))}function S(e){for(const n of l)n.readyState===o.WebSocket.OPEN&&n.send(e)}function b(e){const n={type:"connected",browser:t!==null&&t.readyState===o.WebSocket.OPEN};e.readyState===o.WebSocket.OPEN&&e.send(JSON.stringify(n))}function O(){S(JSON.stringify({type:"connected",browser:!1}))}function E(e){let n;try{n=JSON.parse(e)}catch{return}n.type==="run:complete"&&(a=!1),S(e)}function h(e,n){let r;try{r=JSON.parse(n)}catch{c(e,"INVALID_MESSAGE","Invalid JSON");return}if(!r.type){c(e,"INVALID_MESSAGE",'Missing "type" field');return}if(r.type==="run"){if(!t||t.readyState!==o.WebSocket.OPEN){c(e,"NO_BROWSER","No browser connected");return}if(a){c(e,"RUN_IN_PROGRESS","A test run is already in progress");return}a=!0,t.send(n);return}if(r.type==="status"){if(!t||t.readyState!==o.WebSocket.OPEN){c(e,"NO_BROWSER","No browser connected");return}t.send(n);return}c(e,"UNKNOWN_COMMAND",`Unknown command: ${r.type}`)}function m(e){let n=!1;const r=s=>{const P=typeof s=="string"?s:s.toString();if(!n){let d;try{d=JSON.parse(P)}catch{c(e,"INVALID_MESSAGE","Invalid JSON");return}if(d.type!=="hello"||d.role!=="browser"&&d.role!=="client"){c(e,"INVALID_MESSAGE",'First message must be a hello with role "browser" or "client"');return}n=!0,d.role==="browser"?(t&&t.readyState===o.WebSocket.OPEN&&t.close(1e3,"Replaced by new browser"),t=e,a=!1,e.on("close",()=>{t===e&&(t=null,a=!1,O())}),S(JSON.stringify({type:"connected",browser:!0})),e.on("message",i=>{E(typeof i=="string"?i:i.toString())})):(l.add(e),e.on("close",()=>{l.delete(e)}),b(e),e.on("message",i=>{h(e,typeof i=="string"?i:i.toString())})),e.removeListener("message",r)}};e.on("message",r),e.on("error",s=>{f&&f(s)})}const g=(e,n,r)=>{new URL(e.url??"/",`http://${e.headers.host??"localhost"}`).pathname===p&&u.handleUpgrade(e,n,r,s=>{m(s)})};return y.on("upgrade",g),u.on("error",e=>{f&&f(e)}),{close(){y.removeListener("upgrade",g);for(const e of l)e.close(1e3,"Relay shutting down");l.clear(),t&&t.readyState===o.WebSocket.OPEN&&t.close(1e3,"Relay shutting down"),t=null,a=!1,u.close()},get browserConnected(){return t!==null&&t.readyState===o.WebSocket.OPEN},get clientCount(){return l.size}}}Object.defineProperty(exports,"createTwdRelay",{enumerable:!0,get:function(){return W}});