@foldspace_npm/harness 0.1.34 → 0.1.36

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 CHANGED
@@ -319,6 +319,16 @@ and asking are not the only options — **observing is, and it comes first**:
319
319
  not open a path that does something (`/logout`, `/…/delete`); and `read`
320
320
  only goes to the app's own origin, a host configured for this project, or
321
321
  an origin `screen` saw the page call.
322
+
323
+ **A button you need to look behind** (a report tile, a "View details"):
324
+ ask the human first, naming it - *"May I click 'Staff Overview'? It's a
325
+ button, so it could do something."* Only after their yes in this chat, run
326
+ `npx foldspace observe screen --click "Staff Overview" --human-said-yes`.
327
+ One yes covers that one button, once. Never pass `--human-said-yes`
328
+ without it. Even with a yes, observe refuses a label that reads like an
329
+ action (Export, Send, Delete, Run…), a form submit, a download and signing
330
+ out: hand those to the human. If the result has `buttonWarning`, the button
331
+ sent changes to the server: tell the human what, in one line, first.
322
332
  3. **Ask the human to perform the workflow** — only for what you cannot reach:
323
333
  an MFA step, a paywall, a screen that needs a record the account does not
324
334
  have, or anything that would write.
@@ -327,7 +337,7 @@ Signing in is the human's step, because `inject` launches its own Chrome
327
337
  profile (empty the first time; it keeps the sign-in after that).
328
338
  Navigating is yours. **While you drive a signed-in account you are read-only:**
329
339
  GET only, never submit a form, never click anything that creates, sends or
330
- deletes.
340
+ deletes, and a plain button only after the human's yes (above).
331
341
 
332
342
  Capture at least one real HTTP 200 for the data the action needs. If there is
333
343
  none, pivot; do not create Foldspace resources. Before that address goes into
package/bin/observe.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * foldspace observe pages
8
8
  * foldspace observe menu
9
- * foldspace observe screen [--click "<label>"] [--goto /path | https://<one of this project's hosts>/path] [--match "deal stage"] [--wait 4000]
9
+ * foldspace observe screen [--click "<label>" [--human-said-yes]] [--goto /path | https://<one of this project's hosts>/path] [--match "deal stage"] [--wait 4000]
10
10
  * foldspace observe read <path-or-url>
11
11
  * foldspace observe auth [--wait 5000]
12
12
  * foldspace observe styles
@@ -30,6 +30,7 @@ import {
30
30
  NAV_SCOPE_SELECTOR,
31
31
  analyzeAuthHeaders,
32
32
  buildAuthSummary,
33
+ buttonRefusal,
33
34
  clickRefusal,
34
35
  displayUrl,
35
36
  isCandidateRequest,
@@ -57,7 +58,7 @@ import { resolveProjectDir } from "../src/upgrade.mjs";
57
58
  const SUBCOMMANDS = {
58
59
  pages: { values: [], flags: [] },
59
60
  menu: { values: [], flags: [] },
60
- screen: { values: ["click", "goto", "match", "wait"], flags: [] },
61
+ screen: { values: ["click", "goto", "match", "wait"], flags: ["human-said-yes"] },
61
62
  read: { values: [], flags: [] },
62
63
  auth: { values: ["wait"], flags: [] },
63
64
  styles: { values: [], flags: ["write"] },
@@ -124,11 +125,16 @@ async function evaluateInFrames(page, expression, { stopWhen } = {}) {
124
125
  return values;
125
126
  }
126
127
 
127
- /** Page code shared by `menu` (click = null) and `screen --click`. */
128
- function navigationScript(clickLabel) {
128
+ /**
129
+ * Page code shared by `menu` (click = null) and `screen --click`. With
130
+ * humanSaidYes, a plain button anywhere on the page may be clicked too, once
131
+ * buttonRefusal lets its label through.
132
+ */
133
+ function navigationScript(clickLabel, humanSaidYes = false) {
129
134
  return `(() => {
130
135
  ${pickByLabel}
131
136
  ${clickRefusal}
137
+ ${buttonRefusal}
132
138
  ${VISIBLE_JS}
133
139
  const labelOf = (el) =>
134
140
  (el.innerText || el.textContent || "").replace(/\\s+/g, " ").trim() ||
@@ -181,17 +187,48 @@ function navigationScript(clickLabel) {
181
187
  }
182
188
  const want = ${JSON.stringify(clickLabel ?? null)};
183
189
  if (want === null) return { items };
190
+ // A button the human said yes to: screened by buttonRefusal, then clicked.
191
+ const clickButton = (el, item) => {
192
+ const why = buttonRefusal(item);
193
+ if (why) return { clicked: false, refused: why };
194
+ el.click();
195
+ return { clicked: true, item, button: true };
196
+ };
184
197
  const index = pickByLabel(items, want);
185
198
  if (index < 0) {
186
199
  const lower = want.toLowerCase();
187
- const elsewhere = [...document.querySelectorAll("a, button, input[type=submit], [role=button], [role=menuitem], [role=tab], [role=link]")]
188
- .some((el) => visible(el) && (labelOf(el) || el.value || "").toLowerCase().includes(lower));
200
+ const anywhere = [...document.querySelectorAll("a, button, [role=button], [role=menuitem], [role=tab], [role=link]")]
201
+ .filter((el) => visible(el) && !el.closest("form") && (labelOf(el) || "").length <= 60);
202
+ const elsewhere = anywhere.some((el) => (labelOf(el) || "").toLowerCase().includes(lower));
203
+ if (${JSON.stringify(Boolean(humanSaidYes))} && elsewhere) {
204
+ const found = anywhere.map((el) => ({ el, label: labelOf(el) }));
205
+ const at = pickByLabel(found, want);
206
+ if (at >= 0) {
207
+ const el = found[at].el;
208
+ const href = el.getAttribute("href");
209
+ if (el.tagName === "A" && href && href !== "#" && !/^javascript:/i.test(href)) {
210
+ return { clicked: false, refused: '"' + found[at].label + '" is a link outside the navigation: open it with --goto ' + new URL(href, location.href).pathname + "." };
211
+ }
212
+ return clickButton(el, {
213
+ label: found[at].label,
214
+ kind: kindOf(el),
215
+ path: null,
216
+ submitsForm: false,
217
+ download: el.hasAttribute("download"),
218
+ filePicker: el.matches("input[type=file], label[for]"),
219
+ expands: el.hasAttribute("aria-expanded") || el.hasAttribute("aria-haspopup"),
220
+ });
221
+ }
222
+ }
189
223
  return { clicked: false, outsideNavigation: elsewhere, available: items.map((item) => item.label) };
190
224
  }
191
225
  const refused = clickRefusal(items[index]);
192
- if (refused) return { clicked: false, refused };
193
- elements[index].click();
194
- return { clicked: true, item: items[index] };
226
+ if (!refused) {
227
+ elements[index].click();
228
+ return { clicked: true, item: items[index] };
229
+ }
230
+ if (!${JSON.stringify(Boolean(humanSaidYes))} || items[index].kind === "link" && items[index].path) return { clicked: false, refused };
231
+ return clickButton(elements[index], items[index]);
195
232
  })()`;
196
233
  }
197
234
 
@@ -388,7 +425,7 @@ async function runScreen(page, flags) {
388
425
  if (flags.click !== undefined) await network.settle(Math.min(waitMs, 3000));
389
426
  }
390
427
  if (flags.click !== undefined) {
391
- const results = await evaluateInFrames(page, navigationScript(String(flags.click)), {
428
+ const results = await evaluateInFrames(page, navigationScript(String(flags.click), Boolean(flags["human-said-yes"])), {
392
429
  stopWhen: (value) => value?.clicked === true,
393
430
  });
394
431
  const hit = results.find((value) => value?.clicked);
@@ -398,7 +435,7 @@ async function runScreen(page, flags) {
398
435
  const error = refused
399
436
  ? refused.refused
400
437
  : outside
401
- ? `"${flags.click}" is on the page but not in the app's navigation. observe only clicks navigation, never a control that could create, send, save or delete.`
438
+ ? `"${flags.click}" is on the page but not in the app's navigation. Ask the human in chat whether you may click it, naming it; only after their yes, run this again with --human-said-yes.`
402
439
  : `No navigation item is labelled "${flags.click}".`;
403
440
  throw new ObserveExit(
404
441
  {
@@ -409,7 +446,7 @@ async function runScreen(page, flags) {
409
446
  1,
410
447
  );
411
448
  }
412
- reached = { how: "click", ...publicItem(hit.item) };
449
+ reached = { how: "click", ...publicItem(hit.item), ...(hit.button ? { button: true } : {}) };
413
450
  }
414
451
  if (!reached) {
415
452
  // Nothing to reach: reload so this screen's own requests are seen.
@@ -449,6 +486,7 @@ async function runScreen(page, flags) {
449
486
  writes.set(`${entry.method} ${entry.path}`, entry);
450
487
  }
451
488
  const ranked = rankCandidates(candidates, words, 8);
489
+ const buttonWrote = reached.button && writes.size > 0;
452
490
  return {
453
491
  ok: true,
454
492
  reached,
@@ -457,6 +495,9 @@ async function runScreen(page, flags) {
457
495
  requestsSeen: all.filter(isApiCall).length,
458
496
  candidates: ranked,
459
497
  writesSeen: [...writes.values()].slice(0, 20),
498
+ ...(buttonWrote
499
+ ? { buttonWarning: `The button "${reached.label}" sent ${writes.size} change(s) to the server (writesSeen). Tell the human what it sent, in one line, before anything else.` }
500
+ : {}),
460
501
  ...customerSubdomainFields(url, all.filter(isApiCall).map((request) => request.url)),
461
502
  ...(ranked.length
462
503
  ? {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -162,8 +162,12 @@ async function openCdpSocket(webSocketDebuggerUrl) {
162
162
  export async function inspectCdpUserDataDir(webSocketDebuggerUrl) {
163
163
  const session = await openCdpSocket(webSocketDebuggerUrl);
164
164
  try {
165
+ // background: true - a tab opened in front raises Chrome over whatever
166
+ // app the human is using, on every harness command (measured 2026-10-27:
167
+ // 3 of 3 in front, 0 in the background).
165
168
  const { targetId } = await session.call("Target.createTarget", {
166
169
  url: "chrome://version/",
170
+ background: true,
167
171
  });
168
172
  const attached = await session.call("Target.attachToTarget", {
169
173
  targetId,
@@ -371,6 +371,7 @@ export const CLI_COMMANDS = Object.freeze([
371
371
  ],
372
372
  options: [
373
373
  value("--click", "label", "screen: reach the screen by a navigation label, as a person would"),
374
+ flag("--human-said-yes", "screen --click: the human said yes in chat to clicking this one plain button; a label that reads like an action is still refused"),
374
375
  value("--goto", "path", "screen: a path on this host, or a full https address on a host this project names"),
375
376
  value("--match", "words", "screen: rank the requests seen by these words"),
376
377
  value("--wait", "ms", "screen/auth: how long to keep listening"),
@@ -233,6 +233,28 @@ export function clickRefusal(item) {
233
233
  return `"${label}" is a ${item?.kind || "button"} with no link behind it, and a button can do anything. observe clicks links, tabs and menu openers only - find the screen's path with \`menu\` and use --goto.`;
234
234
  }
235
235
 
236
+ /**
237
+ * For a plain button the human has said yes to in chat (`screen --click
238
+ * "<label>" --human-said-yes`): why observe still will not click it, or null
239
+ * when it may. The yes covers looking, never acting, so a label that reads like
240
+ * an action, a form submit, a download, a file picker and signing out stay
241
+ * refused. Self-contained (injected into the page).
242
+ */
243
+ export function buttonRefusal(item) {
244
+ const label = String(item?.label || "");
245
+ const text = label.replace(/^[^\p{L}\p{N}]+/u, "");
246
+ if (/\b(log|sign)[\s-]?(out|off)\b/i.test(text)) return `"${label}" ends the session; observe never clicks it.`;
247
+ if (item?.submitsForm) return `"${label}" submits a form; observe never clicks it, even with a yes.`;
248
+ if (item?.download) return `"${label}" downloads a file; observe never clicks it, even with a yes.`;
249
+ if (item?.filePicker) return `"${label}" opens a file picker; observe never clicks it, even with a yes.`;
250
+ const acts =
251
+ /\b(delete|remove|destroy|erase|purge|empty|clear|reset|revoke|regenerate|rotate|deactivate|disable|enable|activate|suspend|terminate|cancel|confirm|approve|reject|decline|accept|submit|send|resend|email|share|save|create|add(?![- ]?ons)|new|invite|pay|buy|purchase|charge|refund|upgrade|downgrade|subscribe|unsubscribe|publish|unpublish|archive|restore|import|export|download|upload|print|run|execute|start|stop|pause|resume|merge|transfer|assign|mark|bulk|sync|connect|disconnect|install|uninstall|generate|update|edit|apply|book|schedule|place|checkout|sign|join|leave|follow|unfollow|like|vote)\b/i;
252
+ const hit = text.match(acts);
253
+ return hit
254
+ ? `"${label}" reads like an action ("${hit[1]}"), and a yes covers looking, never acting. Hand this step to the human.`
255
+ : null;
256
+ }
257
+
236
258
  // ------------------------------------------------------------ JSON shapes
237
259
 
238
260
  /**