@moxxy/sdk 0.15.1 → 0.16.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/dist/compactor.d.ts +1 -1
- package/dist/compactor.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/mode/abort-backoff.d.ts +23 -0
- package/dist/mode/abort-backoff.d.ts.map +1 -0
- package/dist/mode/abort-backoff.js +53 -0
- package/dist/mode/abort-backoff.js.map +1 -0
- package/dist/mode/project-messages.d.ts.map +1 -1
- package/dist/mode/project-messages.js +38 -4
- package/dist/mode/project-messages.js.map +1 -1
- package/dist/mode-helpers.d.ts +1 -0
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js +1 -0
- package/dist/mode-helpers.js.map +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/requirements.d.ts +18 -3
- package/dist/requirements.d.ts.map +1 -1
- package/dist/server.d.ts +0 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +0 -1
- package/dist/server.js.map +1 -1
- package/dist/tool-display.d.ts.map +1 -1
- package/dist/tool-display.js +23 -4
- package/dist/tool-display.js.map +1 -1
- package/dist/tunnel.d.ts +21 -34
- package/dist/tunnel.d.ts.map +1 -1
- package/dist/tunnel.js +11 -172
- package/dist/tunnel.js.map +1 -1
- package/dist/view-renderer.d.ts +6 -0
- package/dist/view-renderer.d.ts.map +1 -1
- package/dist/view-renderer.js +17 -3
- package/dist/view-renderer.js.map +1 -1
- package/package.json +1 -1
- package/src/compactor.ts +6 -1
- package/src/index.ts +7 -11
- package/src/loop-helpers.test.ts +72 -0
- package/src/mode/abort-backoff.test.ts +64 -0
- package/src/mode/abort-backoff.ts +53 -0
- package/src/mode/project-messages.ts +35 -4
- package/src/mode-helpers.ts +1 -0
- package/src/package-root.test.ts +0 -2
- package/src/plugin.ts +1 -1
- package/src/requirements.ts +15 -3
- package/src/server.ts +0 -1
- package/src/tool-display.test.ts +24 -0
- package/src/tool-display.ts +22 -6
- package/src/tunnel.ts +19 -198
- package/src/view-renderer.test.ts +12 -0
- package/src/view-renderer.ts +16 -2
- package/src/tunnel.test.ts +0 -99
package/src/requirements.ts
CHANGED
|
@@ -13,16 +13,28 @@ export type RequirementKind =
|
|
|
13
13
|
|
|
14
14
|
export type RequirementState = 'registered' | 'active' | 'ready';
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/** Fields common to every requirement kind. */
|
|
17
|
+
interface RequirementBase {
|
|
18
18
|
readonly name: string;
|
|
19
19
|
readonly state?: RequirementState;
|
|
20
|
-
readonly version?: string;
|
|
21
20
|
readonly optional?: boolean;
|
|
22
21
|
readonly reason?: string;
|
|
23
22
|
readonly hint?: string;
|
|
24
23
|
}
|
|
25
24
|
|
|
25
|
+
/**
|
|
26
|
+
* A declared dependency a plugin/config can require be present (and optionally
|
|
27
|
+
* active/ready). It is a discriminated union on `kind`: `version` is ONLY valid
|
|
28
|
+
* on the `plugin` kind, because that is the sole kind whose target resolves a
|
|
29
|
+
* version (see core's `RequirementRegistry.targetInfo`). A `version` on any
|
|
30
|
+
* other kind would compare against an always-undefined target version and
|
|
31
|
+
* report a permanent spurious `version_mismatch`, so the type forbids it at
|
|
32
|
+
* compile time rather than letting it silently slip through.
|
|
33
|
+
*/
|
|
34
|
+
export type MoxxyRequirement =
|
|
35
|
+
| (RequirementBase & { readonly kind: 'plugin'; readonly version?: string })
|
|
36
|
+
| (RequirementBase & { readonly kind: Exclude<RequirementKind, 'plugin'> });
|
|
37
|
+
|
|
26
38
|
export interface RequirementIssue {
|
|
27
39
|
readonly requirement: MoxxyRequirement;
|
|
28
40
|
readonly code: 'missing' | 'inactive' | 'not_ready' | 'version_mismatch';
|
package/src/server.ts
CHANGED
package/src/tool-display.test.ts
CHANGED
|
@@ -68,4 +68,28 @@ describe('guards', () => {
|
|
|
68
68
|
expect(isToolDisplayResult({ forModel: 'x' })).toBe(false);
|
|
69
69
|
expect(isToolDisplayResult('wrote 12 chars')).toBe(false);
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
it('rejects malformed file-diff objects (not just the right `kind`)', () => {
|
|
73
|
+
// Right kind, but the rest of the shape is bogus — must not be trusted as a
|
|
74
|
+
// structured diff (channels would otherwise render garbage / the model loses
|
|
75
|
+
// the real output).
|
|
76
|
+
expect(isFileDiffDisplay({ kind: 'file-diff' })).toBe(false); // no path/counts/hunks
|
|
77
|
+
expect(isFileDiffDisplay({ kind: 'file-diff', path: 1, added: 1, removed: 0, hunks: [] })).toBe(false); // path not a string
|
|
78
|
+
expect(isFileDiffDisplay({ kind: 'file-diff', path: 'a', added: '1', removed: 0, hunks: [] })).toBe(false); // added not numeric
|
|
79
|
+
expect(isFileDiffDisplay({ kind: 'file-diff', path: 'a', added: 1, removed: 0, hunks: 'nope' })).toBe(false); // hunks not an array
|
|
80
|
+
expect(isFileDiffDisplay({ kind: 'file-diff', path: 'a', added: 1, removed: 0, hunks: [{ lines: 'x' }] })).toBe(false); // hunk.lines not an array
|
|
81
|
+
expect(isFileDiffDisplay({ kind: 'file-diff', path: 'a', added: 1, removed: 0, hunks: [null] })).toBe(false); // bad hunk entry
|
|
82
|
+
// A malformed display must also fail the wrapping ToolDisplayResult guard.
|
|
83
|
+
expect(isToolDisplayResult({ forModel: 'x', display: { kind: 'file-diff' } })).toBe(false);
|
|
84
|
+
// A well-formed hunk passes.
|
|
85
|
+
expect(
|
|
86
|
+
isFileDiffDisplay({
|
|
87
|
+
kind: 'file-diff',
|
|
88
|
+
path: 'a',
|
|
89
|
+
added: 1,
|
|
90
|
+
removed: 0,
|
|
91
|
+
hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: [] }],
|
|
92
|
+
}),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
});
|
|
71
95
|
});
|
package/src/tool-display.ts
CHANGED
|
@@ -72,12 +72,28 @@ export function isToolDisplay(x: unknown): x is ToolDisplay {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export function isFileDiffDisplay(x: unknown): x is FileDiffDisplay {
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
if (typeof x !== 'object' || x === null) return false;
|
|
76
|
+
const d = x as {
|
|
77
|
+
kind?: unknown;
|
|
78
|
+
path?: unknown;
|
|
79
|
+
added?: unknown;
|
|
80
|
+
removed?: unknown;
|
|
81
|
+
hunks?: unknown;
|
|
82
|
+
};
|
|
83
|
+
if (d.kind !== 'file-diff') return false;
|
|
84
|
+
// Validate the load-bearing shape, not just `kind` + a `hunks` array: this
|
|
85
|
+
// guard decides whether a tool result is TRUSTED as a structured diff (the
|
|
86
|
+
// model is then shown only `forModel`, and channels render the payload). A
|
|
87
|
+
// malformed object with a bogus path / non-numeric counts / a non-array hunk
|
|
88
|
+
// must be rejected, not silently rendered.
|
|
89
|
+
if (typeof d.path !== 'string') return false;
|
|
90
|
+
if (typeof d.added !== 'number' || typeof d.removed !== 'number') return false;
|
|
91
|
+
if (!Array.isArray(d.hunks)) return false;
|
|
92
|
+
for (const hunk of d.hunks) {
|
|
93
|
+
if (typeof hunk !== 'object' || hunk === null) return false;
|
|
94
|
+
if (!Array.isArray((hunk as { lines?: unknown }).lines)) return false;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
81
97
|
}
|
|
82
98
|
|
|
83
99
|
/** Human summary, e.g. "Added 10 lines, removed 1 line". */
|
package/src/tunnel.ts
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A swappable way to expose a locally-bound surface (the web channel
|
|
3
|
-
* user over the public internet — so an
|
|
4
|
-
* a URL they can open. One provider is
|
|
5
|
-
* plugins, like every other block); core
|
|
6
|
-
* `getActive()` is non-null.
|
|
2
|
+
* A swappable way to expose a locally-bound surface (the web channel, the mobile
|
|
3
|
+
* bridge, the webhooks listener) to the user over the public internet — so an
|
|
4
|
+
* agent on Telegram/TUI can hand the user a URL they can open. One provider is
|
|
5
|
+
* active per session (registered via plugins, like every other block); core
|
|
6
|
+
* seeds a `localhost` no-op provider so `getActive()` is non-null.
|
|
7
|
+
*
|
|
8
|
+
* The shipped provider is `@moxxy/plugin-tunnel-proxy` (the self-hosted proxy
|
|
9
|
+
* relay). The old cloudflared/ngrok subprocess providers — and the
|
|
10
|
+
* `spawnCliTunnel` helper that backed them — were removed.
|
|
7
11
|
*/
|
|
8
|
-
import { spawn, type ChildProcess } from 'node:child_process';
|
|
9
12
|
|
|
10
13
|
export interface TunnelOpenOptions {
|
|
11
14
|
readonly port: number;
|
|
12
15
|
readonly host: string;
|
|
16
|
+
/**
|
|
17
|
+
* Optional routing label for tunnels that multiplex several local services
|
|
18
|
+
* under one public endpoint (the proxy relay): the returned URL is
|
|
19
|
+
* `…/<label>` and inbound requests under that path segment route back to this
|
|
20
|
+
* `host:port`. Providers that expose a single port (or none) ignore it.
|
|
21
|
+
*/
|
|
22
|
+
readonly label?: string;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
export interface TunnelHandle {
|
|
16
|
-
/** The publicly reachable base URL (e.g. https://
|
|
26
|
+
/** The publicly reachable base URL (e.g. https://abc123.proxy.moxxy.ai/mobile). */
|
|
17
27
|
readonly url: string;
|
|
18
|
-
/** Tear the tunnel down (
|
|
28
|
+
/** Tear the tunnel down (deregister the target / close the connection). */
|
|
19
29
|
close(): Promise<void>;
|
|
20
30
|
}
|
|
21
31
|
|
|
@@ -23,195 +33,6 @@ export interface TunnelProviderDef {
|
|
|
23
33
|
readonly name: string;
|
|
24
34
|
/** Open a tunnel to `http://host:port`, resolving once the public URL is known. */
|
|
25
35
|
open(opts: TunnelOpenOptions): Promise<TunnelHandle>;
|
|
26
|
-
/** Optional readiness gate (e.g. the
|
|
36
|
+
/** Optional readiness gate (e.g. the relay is reachable). */
|
|
27
37
|
isAvailable?(): Promise<boolean>;
|
|
28
38
|
}
|
|
29
|
-
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
// Shared CLI-tunnel subprocess management
|
|
32
|
-
//
|
|
33
|
-
// cloudflared/ngrok (channel-web) and the webhooks tunnel all do the same
|
|
34
|
-
// thing: spawn a CLI, watch its stdout/stderr for the assigned public URL,
|
|
35
|
-
// resolve once it's seen (or reject on timeout/exit/error), and guarantee the
|
|
36
|
-
// child is killed on close *and* on process exit (Node does not reap children).
|
|
37
|
-
// `spawnCliTunnel` is the single implementation they all share.
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Guarantees spawned tunnel children (cloudflared/ngrok/…) never orphan or
|
|
42
|
-
* leak. Node does NOT kill child processes when the parent exits, so we track
|
|
43
|
-
* every live child and kill any survivors on process teardown — in addition to
|
|
44
|
-
* the explicit `close()` path. Each tracked child is also force-killed
|
|
45
|
-
* (SIGKILL) if it ignores SIGTERM, so a wedged tunnel can't drain
|
|
46
|
-
* memory/handles.
|
|
47
|
-
*/
|
|
48
|
-
const liveChildren = new Set<ChildProcess>();
|
|
49
|
-
let exitHookInstalled = false;
|
|
50
|
-
|
|
51
|
-
function killChild(child: ChildProcess): void {
|
|
52
|
-
if (child.exitCode != null || child.signalCode != null) return;
|
|
53
|
-
try {
|
|
54
|
-
child.kill('SIGTERM');
|
|
55
|
-
} catch {
|
|
56
|
-
/* already gone */
|
|
57
|
-
}
|
|
58
|
-
// Escalate if it doesn't exit promptly. unref so this timer never holds the
|
|
59
|
-
// event loop open on its own.
|
|
60
|
-
const t = setTimeout(() => {
|
|
61
|
-
try {
|
|
62
|
-
child.kill('SIGKILL');
|
|
63
|
-
} catch {
|
|
64
|
-
/* gone */
|
|
65
|
-
}
|
|
66
|
-
}, 2000);
|
|
67
|
-
t.unref?.();
|
|
68
|
-
child.once('exit', () => clearTimeout(t));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function ensureExitHook(): void {
|
|
72
|
-
if (exitHookInstalled) return;
|
|
73
|
-
exitHookInstalled = true;
|
|
74
|
-
const killAll = (): void => {
|
|
75
|
-
for (const child of liveChildren) {
|
|
76
|
-
try {
|
|
77
|
-
child.kill('SIGKILL'); // process is exiting; be decisive, no async escalation
|
|
78
|
-
} catch {
|
|
79
|
-
/* gone */
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
liveChildren.clear();
|
|
83
|
-
};
|
|
84
|
-
process.once('exit', killAll);
|
|
85
|
-
// Registering a SIGINT/SIGTERM listener suppresses Node's default
|
|
86
|
-
// terminate-on-signal behavior, so an entrypoint that spawns a tunnel but
|
|
87
|
-
// installs no exit handler of its own would swallow the first Ctrl-C and hang
|
|
88
|
-
// with the process still alive. Re-raise the signal after cleanup so the
|
|
89
|
-
// default termination still fires. `process.once` removes the listener before
|
|
90
|
-
// it runs, so re-raising hits Node's default disposition (terminate).
|
|
91
|
-
const onSignal = (sig: 'SIGINT' | 'SIGTERM') => (): void => {
|
|
92
|
-
killAll();
|
|
93
|
-
process.kill(process.pid, sig);
|
|
94
|
-
};
|
|
95
|
-
process.once('SIGINT', onSignal('SIGINT'));
|
|
96
|
-
process.once('SIGTERM', onSignal('SIGTERM'));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Track a spawned child; returns an `untrack()` that also kills it cleanly. */
|
|
100
|
-
function trackChild(child: ChildProcess): () => Promise<void> {
|
|
101
|
-
ensureExitHook();
|
|
102
|
-
liveChildren.add(child);
|
|
103
|
-
child.once('exit', () => liveChildren.delete(child));
|
|
104
|
-
return () =>
|
|
105
|
-
new Promise<void>((resolve) => {
|
|
106
|
-
liveChildren.delete(child);
|
|
107
|
-
if (child.exitCode != null || child.signalCode != null) return resolve();
|
|
108
|
-
child.once('exit', () => resolve());
|
|
109
|
-
killChild(child);
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface SpawnCliTunnelOptions {
|
|
114
|
-
/** Executable to spawn (e.g. `cloudflared`, `ngrok`). */
|
|
115
|
-
readonly cmd: string;
|
|
116
|
-
/** Arguments passed to the executable. */
|
|
117
|
-
readonly args: ReadonlyArray<string>;
|
|
118
|
-
/** Matches the assigned public URL in a chunk of the CLI's stdout/stderr. */
|
|
119
|
-
readonly urlRegex: RegExp;
|
|
120
|
-
/** How long to wait for the URL before giving up. Default 30s. */
|
|
121
|
-
readonly timeoutMs?: number;
|
|
122
|
-
/** Human-readable name used in error messages. Defaults to `cmd`. */
|
|
123
|
-
readonly name?: string;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** A spawned CLI tunnel: the public-URL handle plus the child's pid. */
|
|
127
|
-
export interface CliTunnelHandle extends TunnelHandle {
|
|
128
|
-
/** Spawned process id (-1 if the platform didn't assign one). */
|
|
129
|
-
readonly pid: number;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const DEFAULT_TUNNEL_URL_TIMEOUT_MS = 30_000;
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Spawn a CLI tunnel, parse the assigned public URL out of its output, and
|
|
136
|
-
* resolve a {@link CliTunnelHandle}. The child is tracked so it is killed on
|
|
137
|
-
* `close()`, on tunnel-switch, and on process exit (no orphans). Rejects on
|
|
138
|
-
* timeout, spawn error, or premature exit (the child is killed in every reject
|
|
139
|
-
* path). This is the single spawn-and-parse implementation behind the
|
|
140
|
-
* cloudflared/ngrok tunnel providers and the webhooks tunnel.
|
|
141
|
-
*/
|
|
142
|
-
export function spawnCliTunnel(opts: SpawnCliTunnelOptions): Promise<CliTunnelHandle> {
|
|
143
|
-
const { cmd, args, urlRegex } = opts;
|
|
144
|
-
const name = opts.name ?? cmd;
|
|
145
|
-
const timeoutMs = opts.timeoutMs ?? DEFAULT_TUNNEL_URL_TIMEOUT_MS;
|
|
146
|
-
|
|
147
|
-
return new Promise<CliTunnelHandle>((resolve, reject) => {
|
|
148
|
-
let child: ChildProcess;
|
|
149
|
-
try {
|
|
150
|
-
child = spawn(cmd, args as string[], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
151
|
-
} catch (err) {
|
|
152
|
-
reject(
|
|
153
|
-
new Error(
|
|
154
|
-
`failed to spawn ${name} — is it installed? (${err instanceof Error ? err.message : String(err)})`,
|
|
155
|
-
),
|
|
156
|
-
);
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const untrack = trackChild(child);
|
|
161
|
-
let settled = false;
|
|
162
|
-
|
|
163
|
-
const timer = setTimeout(() => {
|
|
164
|
-
if (settled) return;
|
|
165
|
-
settled = true;
|
|
166
|
-
void untrack();
|
|
167
|
-
reject(new Error(`${name}: timed out after ${timeoutMs}ms waiting for the tunnel URL`));
|
|
168
|
-
}, timeoutMs);
|
|
169
|
-
timer.unref?.();
|
|
170
|
-
|
|
171
|
-
// 'data' events are not line-buffered: the assigned URL can straddle two
|
|
172
|
-
// chunk boundaries (large URL, fragmented flush). Match against a rolling
|
|
173
|
-
// accumulation rather than each lone chunk, capped so a chatty tunnel that
|
|
174
|
-
// never prints a URL can't grow this unboundedly while we wait.
|
|
175
|
-
const MAX_BUF = 8192;
|
|
176
|
-
let acc = '';
|
|
177
|
-
const onData = (buf: Buffer): void => {
|
|
178
|
-
if (settled) return; // drain quietly once resolved so the pipe never fills
|
|
179
|
-
acc += buf.toString('utf8');
|
|
180
|
-
if (acc.length > MAX_BUF) acc = acc.slice(acc.length - MAX_BUF);
|
|
181
|
-
const url = urlRegex.exec(acc)?.[0] ?? null;
|
|
182
|
-
if (!url) return;
|
|
183
|
-
settled = true;
|
|
184
|
-
acc = '';
|
|
185
|
-
clearTimeout(timer);
|
|
186
|
-
resolve({ url, pid: child.pid ?? -1, close: untrack });
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
child.stdout?.on('data', onData);
|
|
190
|
-
child.stderr?.on('data', onData);
|
|
191
|
-
child.on('error', (err) => {
|
|
192
|
-
if (settled) return;
|
|
193
|
-
settled = true;
|
|
194
|
-
clearTimeout(timer);
|
|
195
|
-
void untrack();
|
|
196
|
-
reject(err);
|
|
197
|
-
});
|
|
198
|
-
child.on('exit', (code) => {
|
|
199
|
-
if (settled) return;
|
|
200
|
-
settled = true;
|
|
201
|
-
clearTimeout(timer);
|
|
202
|
-
reject(new Error(`${name} exited (code ${code ?? 'null'}) before emitting a URL`));
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Probe whether a tunnel CLI is installed and runnable (`<cmd> --version`
|
|
209
|
-
* exits 0). Used as the `isAvailable()` gate by CLI-backed tunnel providers.
|
|
210
|
-
*/
|
|
211
|
-
export function isCliTunnelAvailable(cmd: string): Promise<boolean> {
|
|
212
|
-
return new Promise<boolean>((resolve) => {
|
|
213
|
-
const child = spawn(cmd, ['--version'], { stdio: 'ignore' });
|
|
214
|
-
child.once('error', () => resolve(false));
|
|
215
|
-
child.once('exit', (code) => resolve(code === 0));
|
|
216
|
-
});
|
|
217
|
-
}
|
|
@@ -33,6 +33,18 @@ describe('countNodes', () => {
|
|
|
33
33
|
it('counts a leaf element as 1', () => {
|
|
34
34
|
expect(countNodes(el('hr'))).toBe(1);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
it('does not throw (RangeError) on a deeply nested AST', () => {
|
|
38
|
+
// A hand-built/corrupt AST can be arbitrarily deep; the count must be
|
|
39
|
+
// iterative so a deep chain never blows the call stack. 100k deep would
|
|
40
|
+
// overflow naive recursion.
|
|
41
|
+
const depth = 100_000;
|
|
42
|
+
let node: ViewNode = text('leaf');
|
|
43
|
+
for (let i = 0; i < depth; i++) node = el('wrap', [node]);
|
|
44
|
+
// depth wrapping elements + the leaf text node.
|
|
45
|
+
expect(() => countNodes(node)).not.toThrow();
|
|
46
|
+
expect(countNodes(node)).toBe(depth + 1);
|
|
47
|
+
});
|
|
36
48
|
});
|
|
37
49
|
|
|
38
50
|
describe('view vocabulary integrity', () => {
|
package/src/view-renderer.ts
CHANGED
|
@@ -188,8 +188,22 @@ export const DEFAULT_VIEW_TAGS: ReadonlyArray<ViewTagSpec> = [...VIEW_PRIMITIVES
|
|
|
188
188
|
* Count element + text nodes in a view tree (the `nodeCount` a `present_view`
|
|
189
189
|
* tool reports). Single source of truth: core's parser and the plugin-view tool
|
|
190
190
|
* both import this rather than re-implementing the recursion.
|
|
191
|
+
*
|
|
192
|
+
* Iterative (explicit stack) rather than recursive: a deeply nested
|
|
193
|
+
* hand-built/corrupt AST would blow the call stack with a `RangeError`, and this
|
|
194
|
+
* runs on untrusted agent-authored input. Each node counts as 1; an element's
|
|
195
|
+
* children are pushed for later counting. Order-independent (we only sum), so
|
|
196
|
+
* the count is byte-identical to the old recursion for any normal tree.
|
|
191
197
|
*/
|
|
192
198
|
export function countNodes(node: ViewNode): number {
|
|
193
|
-
|
|
194
|
-
|
|
199
|
+
let count = 0;
|
|
200
|
+
const stack: ViewNode[] = [node];
|
|
201
|
+
while (stack.length > 0) {
|
|
202
|
+
const current = stack.pop()!;
|
|
203
|
+
count += 1;
|
|
204
|
+
if (current.kind === 'element') {
|
|
205
|
+
for (const child of current.children) stack.push(child);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return count;
|
|
195
209
|
}
|
package/src/tunnel.test.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { isCliTunnelAvailable, spawnCliTunnel } from './tunnel.js';
|
|
3
|
-
|
|
4
|
-
const URL_RE = /https:\/\/[a-z0-9-]+\.example\.test/i;
|
|
5
|
-
|
|
6
|
-
describe('spawnCliTunnel', () => {
|
|
7
|
-
it('resolves the parsed URL + pid when the child prints a matching line', async () => {
|
|
8
|
-
const handle = await spawnCliTunnel({
|
|
9
|
-
cmd: process.execPath,
|
|
10
|
-
// Print a noise line, then the URL, then stay alive so close() can kill it.
|
|
11
|
-
args: ['-e', 'console.log("starting up"); console.log("url=https://abc-123.example.test ready"); setInterval(()=>{}, 1000);'],
|
|
12
|
-
urlRegex: URL_RE,
|
|
13
|
-
name: 'faketunnel',
|
|
14
|
-
timeoutMs: 5_000,
|
|
15
|
-
});
|
|
16
|
-
expect(handle.url).toBe('https://abc-123.example.test');
|
|
17
|
-
expect(handle.pid).toBeGreaterThan(0);
|
|
18
|
-
await handle.close();
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('parses URLs printed on stderr too', async () => {
|
|
22
|
-
const handle = await spawnCliTunnel({
|
|
23
|
-
cmd: process.execPath,
|
|
24
|
-
args: ['-e', 'console.error("https://err-host.example.test"); setInterval(()=>{}, 1000);'],
|
|
25
|
-
urlRegex: URL_RE,
|
|
26
|
-
timeoutMs: 5_000,
|
|
27
|
-
});
|
|
28
|
-
expect(handle.url).toBe('https://err-host.example.test');
|
|
29
|
-
await handle.close();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('u125-1: resolves a URL split across two stdout chunks', async () => {
|
|
33
|
-
// Write the URL in two separate process.stdout.write calls, split
|
|
34
|
-
// mid-host, with a tick gap so they surface as distinct 'data' events.
|
|
35
|
-
// Matching each chunk in isolation would miss it; the rolling buffer must
|
|
36
|
-
// stitch them.
|
|
37
|
-
const handle = await spawnCliTunnel({
|
|
38
|
-
cmd: process.execPath,
|
|
39
|
-
args: [
|
|
40
|
-
'-e',
|
|
41
|
-
'process.stdout.write("ready https://abc-1");' +
|
|
42
|
-
'setTimeout(()=>{process.stdout.write("23.example.test\\n");},50);' +
|
|
43
|
-
'setInterval(()=>{}, 1000);',
|
|
44
|
-
],
|
|
45
|
-
urlRegex: URL_RE,
|
|
46
|
-
name: 'splittunnel',
|
|
47
|
-
timeoutMs: 5_000,
|
|
48
|
-
});
|
|
49
|
-
expect(handle.url).toBe('https://abc-123.example.test');
|
|
50
|
-
await handle.close();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('rejects when the child exits before emitting a URL', async () => {
|
|
54
|
-
await expect(
|
|
55
|
-
spawnCliTunnel({
|
|
56
|
-
cmd: process.execPath,
|
|
57
|
-
args: ['-e', 'process.exit(3)'],
|
|
58
|
-
urlRegex: URL_RE,
|
|
59
|
-
name: 'faketunnel',
|
|
60
|
-
timeoutMs: 5_000,
|
|
61
|
-
}),
|
|
62
|
-
).rejects.toThrow(/faketunnel exited \(code 3\) before emitting a URL/);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('rejects when the executable cannot be spawned', async () => {
|
|
66
|
-
await expect(
|
|
67
|
-
spawnCliTunnel({
|
|
68
|
-
cmd: 'definitely-not-a-real-binary-xyz',
|
|
69
|
-
args: [],
|
|
70
|
-
urlRegex: URL_RE,
|
|
71
|
-
name: 'nope',
|
|
72
|
-
timeoutMs: 2_000,
|
|
73
|
-
}),
|
|
74
|
-
).rejects.toThrow();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('rejects on timeout when no URL is ever printed (and kills the child)', async () => {
|
|
78
|
-
await expect(
|
|
79
|
-
spawnCliTunnel({
|
|
80
|
-
cmd: process.execPath,
|
|
81
|
-
args: ['-e', 'setInterval(()=>{}, 1000);'],
|
|
82
|
-
urlRegex: URL_RE,
|
|
83
|
-
name: 'slowtunnel',
|
|
84
|
-
timeoutMs: 150,
|
|
85
|
-
}),
|
|
86
|
-
).rejects.toThrow(/slowtunnel: timed out after 150ms/);
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe('isCliTunnelAvailable', () => {
|
|
91
|
-
it('resolves true for an installed runnable binary', async () => {
|
|
92
|
-
// node is always present in the test runtime and supports --version.
|
|
93
|
-
expect(await isCliTunnelAvailable(process.execPath)).toBe(true);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('resolves false for a missing binary', async () => {
|
|
97
|
-
expect(await isCliTunnelAvailable('definitely-not-a-real-binary-xyz')).toBe(false);
|
|
98
|
-
});
|
|
99
|
-
});
|