@foldspace_npm/harness 0.1.15 → 0.1.16
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/CLAUDE.md +46 -19
- package/README.md +2 -2
- package/bin/attach.mjs +4 -1
- package/bin/inject.mjs +3 -0
- package/bin/observe.mjs +790 -0
- package/package.json +1 -1
- package/src/cdp-client.mjs +233 -0
- package/src/cli-help.mjs +2 -1
- package/src/cli-registry.mjs +53 -3
- package/src/observe-core.mjs +627 -0
- package/src/session-events.mjs +42 -0
- package/templates/agent-starter/README.md +2 -2
package/package.json
CHANGED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// A small CDP page client for the read-only verbs (`observe`, `ask`, `run`).
|
|
2
|
+
//
|
|
3
|
+
// It is a second client on the inject Chrome and is safe to use while
|
|
4
|
+
// `foldspace attach` is running: attach's claim on the debug port is about
|
|
5
|
+
// request interception (`Fetch.enable`) and navigation, and nothing here
|
|
6
|
+
// enables Fetch. `Runtime.evaluate`, `Network.enable` and
|
|
7
|
+
// `Page.captureScreenshot` from a second client do not disturb it.
|
|
8
|
+
//
|
|
9
|
+
// Uses the global WebSocket, as bin/attach.mjs does.
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { assertOwnedCdp, chromeProfileDir, ownershipErrorMessage } from "./cdp-ownership.mjs";
|
|
15
|
+
import { resolveProjectDir } from "./upgrade.mjs";
|
|
16
|
+
|
|
17
|
+
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
+
|
|
19
|
+
/** Where inject recorded the Chrome it launched. Never guess a port. */
|
|
20
|
+
export function readLaunchState(projectDir = resolveProjectDir()) {
|
|
21
|
+
const statePath = path.join(projectDir, ".foldspace-dev", "state.json");
|
|
22
|
+
if (!fs.existsSync(statePath)) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"No .foldspace-dev/state.json here. Run `npm run inject` first, from the project folder.",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
28
|
+
const port = process.env.CDP_PORT || state.debugPort;
|
|
29
|
+
if (!port) throw new Error("state.json has no debugPort. Run `npm run inject` again.");
|
|
30
|
+
const hosts = state.resolvedTarget?.hosts || [];
|
|
31
|
+
return {
|
|
32
|
+
projectDir,
|
|
33
|
+
port: String(port),
|
|
34
|
+
hosts,
|
|
35
|
+
target: state.resolvedTarget || {},
|
|
36
|
+
sentinel: state.sentinel,
|
|
37
|
+
profileDir: chromeProfileDir(projectDir),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function listTargets(port) {
|
|
42
|
+
let response;
|
|
43
|
+
try {
|
|
44
|
+
response = await fetch(`http://127.0.0.1:${port}/json/list`);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`The inject Chrome is not answering on :${port}. It was closed; run \`npm run inject\` again.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return response.json();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hostMatches(url, hosts) {
|
|
54
|
+
try {
|
|
55
|
+
const host = new URL(url).hostname;
|
|
56
|
+
return hosts.some((pattern) => {
|
|
57
|
+
const bare = String(pattern).replace(/^\*\./, "");
|
|
58
|
+
return host === bare || host.endsWith(`.${bare}`);
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The human's tab: the page on the product's host. When the project names its
|
|
67
|
+
* hosts and no tab is on one of them, that is an error, never "the first tab":
|
|
68
|
+
* these commands reload, click and read with a session attached, and the first
|
|
69
|
+
* tab may be anything the human opened.
|
|
70
|
+
*/
|
|
71
|
+
export function choosePage(pages, hosts = []) {
|
|
72
|
+
const real = pages.filter(
|
|
73
|
+
(target) => target.type === "page" && !String(target.url).startsWith("devtools://"),
|
|
74
|
+
);
|
|
75
|
+
if (!real.length) throw new Error("The test window has no open page.");
|
|
76
|
+
const match = real.find((page) => hostMatches(page.url, hosts));
|
|
77
|
+
if (match) return match;
|
|
78
|
+
if (hosts.length) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`The test window has no tab on ${hosts.join(", ")}. Open the product there (or finish signing in), then try again.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return real[0];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function pickPage(port, hosts = []) {
|
|
87
|
+
return choosePage(await listTargets(port), hosts);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// One check per process: it opens a browser-level session.
|
|
91
|
+
let ownershipVerified = false;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Prove the Chrome on this port is the one `inject` launched for THIS project -
|
|
95
|
+
* same check as `attach`. A stale port, or CDP_PORT pointing elsewhere, would
|
|
96
|
+
* otherwise have these commands reload and click in someone else's browser.
|
|
97
|
+
*/
|
|
98
|
+
export async function assertTestWindow(state, verify = assertOwnedCdp) {
|
|
99
|
+
if (ownershipVerified) return;
|
|
100
|
+
const owned = await verify({ profileDir: state.profileDir, port: state.port, token: state.sentinel });
|
|
101
|
+
if (!owned.ok) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
ownershipErrorMessage(owned.reason, {
|
|
104
|
+
port: state.port,
|
|
105
|
+
profileDir: state.profileDir,
|
|
106
|
+
root: state.projectDir,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
ownershipVerified = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class CdpPage {
|
|
114
|
+
#socket;
|
|
115
|
+
#nextId = 0;
|
|
116
|
+
#pending = new Map();
|
|
117
|
+
#listeners = new Map();
|
|
118
|
+
|
|
119
|
+
constructor(socket, info) {
|
|
120
|
+
this.#socket = socket;
|
|
121
|
+
this.info = info;
|
|
122
|
+
socket.addEventListener("message", (event) => this.#dispatch(JSON.parse(event.data)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static async open({ projectDir } = {}) {
|
|
126
|
+
const state = readLaunchState(projectDir);
|
|
127
|
+
await assertTestWindow(state);
|
|
128
|
+
const page = await pickPage(state.port, state.hosts);
|
|
129
|
+
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
|
130
|
+
await new Promise((resolve, reject) => {
|
|
131
|
+
socket.addEventListener("open", resolve, { once: true });
|
|
132
|
+
socket.addEventListener("error", () => reject(new Error("Could not open the CDP socket.")), {
|
|
133
|
+
once: true,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
const client = new CdpPage(socket, { ...state, url: page.url, title: page.title });
|
|
137
|
+
await client.send("Runtime.enable");
|
|
138
|
+
await client.send("Page.enable");
|
|
139
|
+
return client;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#dispatch(message) {
|
|
143
|
+
if (message.id && this.#pending.has(message.id)) {
|
|
144
|
+
const { resolve, reject } = this.#pending.get(message.id);
|
|
145
|
+
this.#pending.delete(message.id);
|
|
146
|
+
if (message.error) reject(new Error(message.error.message));
|
|
147
|
+
else resolve(message.result);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const listener of this.#listeners.get(message.method) || []) listener(message.params);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
send(method, params = {}, { timeoutMs = 30000 } = {}) {
|
|
154
|
+
const id = ++this.#nextId;
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const timer = setTimeout(() => {
|
|
157
|
+
if (this.#pending.delete(id)) {
|
|
158
|
+
reject(new Error(`CDP ${method} did not answer within ${timeoutMs}ms.`));
|
|
159
|
+
}
|
|
160
|
+
}, timeoutMs);
|
|
161
|
+
this.#pending.set(id, {
|
|
162
|
+
resolve: (value) => {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
resolve(value);
|
|
165
|
+
},
|
|
166
|
+
reject: (error) => {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
reject(error);
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
this.#socket.send(JSON.stringify({ id, method, params }));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
on(method, listener) {
|
|
176
|
+
if (!this.#listeners.has(method)) this.#listeners.set(method, []);
|
|
177
|
+
this.#listeners.get(method).push(listener);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Evaluate the body of an async function in the page; returns its JSON value. */
|
|
181
|
+
async evaluate(body, { timeoutMs = 30000 } = {}) {
|
|
182
|
+
const result = await this.send(
|
|
183
|
+
"Runtime.evaluate",
|
|
184
|
+
{ expression: `(async () => { ${body} })()`, awaitPromise: true, returnByValue: true },
|
|
185
|
+
{ timeoutMs },
|
|
186
|
+
);
|
|
187
|
+
if (result.exceptionDetails) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
result.exceptionDetails.exception?.description ||
|
|
190
|
+
result.exceptionDetails.text ||
|
|
191
|
+
"evaluate failed",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return result.result?.value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** PNG screenshot of the viewport, or of `clip` ({x,y,width,height}). */
|
|
198
|
+
async screenshot(filePath, clip) {
|
|
199
|
+
const params = { format: "png" };
|
|
200
|
+
if (clip) params.clip = { ...clip, scale: 1 };
|
|
201
|
+
const shot = await this.send("Page.captureScreenshot", params);
|
|
202
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
203
|
+
fs.writeFileSync(filePath, Buffer.from(shot.data, "base64"));
|
|
204
|
+
return filePath;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
close() {
|
|
208
|
+
try {
|
|
209
|
+
this.#socket.close();
|
|
210
|
+
} catch {
|
|
211
|
+
// already closed
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Parse `--flag value` / `--flag` out of argv; returns { flags, rest }. */
|
|
217
|
+
export function parseArgs(argv, valueFlags = []) {
|
|
218
|
+
const flags = {};
|
|
219
|
+
const rest = [];
|
|
220
|
+
for (let index = 0; index < argv.length; index++) {
|
|
221
|
+
const arg = argv[index];
|
|
222
|
+
if (arg.startsWith("--")) {
|
|
223
|
+
const name = arg.slice(2);
|
|
224
|
+
if (valueFlags.includes(name)) flags[name] = argv[++index];
|
|
225
|
+
else flags[name] = true;
|
|
226
|
+
} else rest.push(arg);
|
|
227
|
+
}
|
|
228
|
+
return { flags, rest };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function printJson(value) {
|
|
232
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
233
|
+
}
|
package/src/cli-help.mjs
CHANGED
|
@@ -49,7 +49,8 @@ export function renderGeneralHelp(registry) {
|
|
|
49
49
|
" foldspace upgrade --check Compare the installed pin to npm latest",
|
|
50
50
|
"",
|
|
51
51
|
"attach loads local actions and observes the normal agent experience.",
|
|
52
|
-
"
|
|
52
|
+
"observe, ask and run look at and test that experience without the customer's help.",
|
|
53
|
+
...(registry.publishWorkflow.length ? ["deploy is a separate remote publication step."] : []),
|
|
53
54
|
);
|
|
54
55
|
return sections.join("\n");
|
|
55
56
|
}
|
package/src/cli-registry.mjs
CHANGED
|
@@ -276,8 +276,53 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
276
276
|
"Detach with Ctrl-C, or foldspace attach --stop for a daemon",
|
|
277
277
|
],
|
|
278
278
|
}),
|
|
279
|
+
Object.freeze({
|
|
280
|
+
name: "observe",
|
|
281
|
+
entry: "observe.mjs",
|
|
282
|
+
group: "verify",
|
|
283
|
+
summary: "Look at the signed-in test window, read-only: screens, requests, auth, styles",
|
|
284
|
+
usage:
|
|
285
|
+
"foldspace observe <pages|menu|screen|read|auth|styles|screenshot|wait-login> [options]",
|
|
286
|
+
risk: "browser-session",
|
|
287
|
+
environment: "local-chrome",
|
|
288
|
+
environmentVariables: ["FOLDSPACE_PROJECT_DIR", "CDP_PORT"],
|
|
289
|
+
capabilities: ["browser.cdp", "page.evaluate"],
|
|
290
|
+
positionals: [
|
|
291
|
+
Object.freeze({
|
|
292
|
+
name: "verb",
|
|
293
|
+
required: true,
|
|
294
|
+
description:
|
|
295
|
+
"pages | menu | screen | read <path> | auth | styles | screenshot | wait-login",
|
|
296
|
+
}),
|
|
297
|
+
Object.freeze({
|
|
298
|
+
name: "path",
|
|
299
|
+
required: false,
|
|
300
|
+
description: "read: the GET path or same-site URL to replay",
|
|
301
|
+
}),
|
|
302
|
+
],
|
|
303
|
+
options: [
|
|
304
|
+
value("--click", "label", "screen: reach the screen by a navigation label, as a person would"),
|
|
305
|
+
value("--goto", "path", "screen: a same-origin path"),
|
|
306
|
+
value("--match", "words", "screen: rank the requests seen by these words"),
|
|
307
|
+
value("--wait", "ms", "screen/auth: how long to keep listening"),
|
|
308
|
+
value("--out", "file", "screenshot: where to save the PNG"),
|
|
309
|
+
value("--timeout", "seconds", "wait-login: give up after this long", { default: "600" }),
|
|
310
|
+
],
|
|
311
|
+
prerequisites: ["foldspace inject has opened the test window"],
|
|
312
|
+
effects: [
|
|
313
|
+
"GET only. Never submits a form; clicks navigation elements only",
|
|
314
|
+
"Prints names, shapes and counts - never values, tokens or cookies",
|
|
315
|
+
"Works while foldspace attach is running",
|
|
316
|
+
],
|
|
317
|
+
next: ["Record what you established in docs/app-profile.md", "Write the handler"],
|
|
318
|
+
}),
|
|
279
319
|
Object.freeze({
|
|
280
320
|
name: "deploy",
|
|
321
|
+
// Foldspace-internal: it uploads to Foldspace's own storage, which no
|
|
322
|
+
// customer can write to. It stays runnable (the deploy pipeline calls it)
|
|
323
|
+
// but is left out of help unless FOLDSPACE_INTERNAL=1, because a coding
|
|
324
|
+
// agent that finds it in the contract offers it to the customer.
|
|
325
|
+
internal: true,
|
|
281
326
|
entry: "deploy.mjs",
|
|
282
327
|
group: "publish",
|
|
283
328
|
summary: "Publish dist/index.js to remote Foldspace action storage",
|
|
@@ -351,7 +396,9 @@ export function commandByName(name) {
|
|
|
351
396
|
return CLI_COMMANDS.find((command) => command.name === name) || null;
|
|
352
397
|
}
|
|
353
398
|
|
|
354
|
-
export function createCliRegistry({ packageName, packageVersion }) {
|
|
399
|
+
export function createCliRegistry({ packageName, packageVersion, env = process.env }) {
|
|
400
|
+
const showInternal = env.FOLDSPACE_INTERNAL === "1";
|
|
401
|
+
const visible = CLI_COMMANDS.filter((command) => showInternal || !command.internal);
|
|
355
402
|
const diagnostics = diagnosticCatalogue()
|
|
356
403
|
.filter((diagnostic) => PUBLIC_DIAGNOSTICS.has(diagnostic.name))
|
|
357
404
|
.map((diagnostic) => ({
|
|
@@ -368,7 +415,8 @@ export function createCliRegistry({ packageName, packageVersion }) {
|
|
|
368
415
|
},
|
|
369
416
|
protocolVersion: HARNESS_PROTOCOL_VERSION,
|
|
370
417
|
workflow: ["init", "build", "inject", "attach"],
|
|
371
|
-
|
|
418
|
+
verifyWorkflow: ["observe"],
|
|
419
|
+
publishWorkflow: showInternal ? ["deploy"] : [],
|
|
372
420
|
capabilities: CAPABILITY_CATALOGUE.map(([id, description]) => ({
|
|
373
421
|
id,
|
|
374
422
|
description,
|
|
@@ -378,7 +426,7 @@ export function createCliRegistry({ packageName, packageVersion }) {
|
|
|
378
426
|
id,
|
|
379
427
|
description,
|
|
380
428
|
})),
|
|
381
|
-
commands:
|
|
429
|
+
commands: visible.map(({ entry: _entry, internal: _internal, ...command }) => command),
|
|
382
430
|
diagnostics,
|
|
383
431
|
notes: [
|
|
384
432
|
"Capability metadata describes current behavior; it is not hosted-environment enforcement.",
|
|
@@ -387,6 +435,8 @@ export function createCliRegistry({ packageName, packageVersion }) {
|
|
|
387
435
|
"attach diagnostics are attach-internal; interpret them from the lifecycle log, not as CLI commands.",
|
|
388
436
|
"Coding agents should run attach --daemon; foreground attach is for humans watching the terminal.",
|
|
389
437
|
"If update.outdated is true, ask the user before foldspace upgrade --yes.",
|
|
438
|
+
"observe, ask and run are read-only second clients on the test window and work while attach runs.",
|
|
439
|
+
"There is no customer-run publication step: putting actions in front of end users is done with Foldspace's team.",
|
|
390
440
|
],
|
|
391
441
|
};
|
|
392
442
|
}
|