@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- "deploy is a separate remote publication step.",
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
  }
@@ -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
- publishWorkflow: ["deploy"],
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: CLI_COMMANDS.map(({ entry: _entry, ...command }) => command),
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
  }