@foldspace_npm/harness 0.1.2 → 0.1.3

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/bin/inject.mjs CHANGED
@@ -2,28 +2,24 @@
2
2
  /**
3
3
  * Local Foldspace injector.
4
4
  *
5
- * Generates a throwaway Chrome extension for a target app and launches Chrome
6
- * with it loaded, so you can exercise agent actions against a live site without
7
- * touching the committed production extension in extension/.
5
+ * Launches an isolated Chrome profile for local CDP verification. Action
6
+ * loading and SDK bootstrap are owned by attach.mjs; inject does not generate
7
+ * or load an application extension.
8
8
  *
9
9
  * npm run inject # default target
10
10
  * npm run inject -- --target app
11
11
  * npm run inject -- --url https://app.other.com --product ABC123 --agent other-agent
12
- * npm run inject -- --remote # use deployed actions, not localhost
13
- * npm run inject -- --print # generate only, don't launch
12
+ * npm run inject -- --print # resolve target only, don't launch
14
13
  */
15
14
  import fs from "fs";
16
15
  import path from "path";
17
16
  import os from "os";
18
17
  import { spawn } from "child_process";
19
- import { fileURLToPath } from "url";
20
18
 
21
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
19
  // Resolve the CONSUMING repo, not this package. FOLDSPACE_PROJECT_DIR lets a
23
20
  // hosted builder point the harness at a workspace it controls.
24
21
  const root = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
25
22
  const workDir = path.join(root, ".foldspace-dev");
26
- const extDir = path.join(workDir, "extension");
27
23
  const themeDir = path.join(workDir, "theme-extension");
28
24
  const profileDir = path.join(workDir, "chrome-profile");
29
25
 
@@ -31,16 +27,37 @@ const profileDir = path.join(workDir, "chrome-profile");
31
27
 
32
28
  function parseArgs(argv) {
33
29
  const out = { flags: new Set() };
30
+ const valueOptions = new Set([
31
+ "--target",
32
+ "--url",
33
+ "--product",
34
+ "--agent",
35
+ "--hosts",
36
+ "--key",
37
+ "--port",
38
+ ]);
34
39
  for (let i = 0; i < argv.length; i++) {
35
40
  const a = argv[i];
36
- if (a === "--remote") out.flags.add("remote");
37
- else if (a === "--print") out.flags.add("print");
41
+ if (a === "--print") out.flags.add("print");
38
42
  else if (a === "--embedded") out.flags.add("embedded");
39
- else if (a.startsWith("--")) out[a.slice(2)] = argv[++i];
43
+ else if (valueOptions.has(a)) {
44
+ if (!argv[i + 1] || argv[i + 1].startsWith("--")) {
45
+ throw new Error(`${a} requires a value`);
46
+ }
47
+ out[a.slice(2)] = argv[++i];
48
+ } else {
49
+ throw new Error(`unknown option '${a}'`);
50
+ }
40
51
  }
41
52
  return out;
42
53
  }
43
- const args = parseArgs(process.argv.slice(2));
54
+ let args;
55
+ try {
56
+ args = parseArgs(process.argv.slice(2));
57
+ } catch (error) {
58
+ console.error(`inject: ${error instanceof Error ? error.message : String(error)}`);
59
+ process.exit(1);
60
+ }
44
61
 
45
62
  // ---------------------------------------------------------------- config
46
63
 
@@ -62,13 +79,12 @@ if (!args.url && !base.startUrl) {
62
79
 
63
80
  const startUrl = args.url || base.startUrl;
64
81
 
65
- // Derive host patterns from the URL when not configured registrable domain + wildcard.
82
+ // Without explicit hosts, fail closed to the exact launch hostname. Expanding
83
+ // registrable domains correctly requires public-suffix data.
66
84
  function hostsFor(urlStr, configured) {
67
85
  if (configured && configured.length) return configured;
68
86
  const { hostname } = new URL(urlStr);
69
- const parts = hostname.split(".");
70
- const apex = parts.length > 2 ? parts.slice(-2).join(".") : hostname;
71
- return [apex, `*.${apex}`];
87
+ return [hostname];
72
88
  }
73
89
 
74
90
  // A --url with no --target means we are pointing at an app the config does not
@@ -88,9 +104,7 @@ const target = {
88
104
  agentApiName: args.agent || (explicitUrl ? null : base.agentApiName),
89
105
  hosts: hostsFor(startUrl, configuredHosts),
90
106
  mode: args.flags.has("embedded") ? "EMBEDDED" : base.mode || "OVERLAY",
91
- loadLocally: args.flags.has("remote") ? false : base.loadLocally !== false,
92
107
  overrideKey: args.key || base.overrideKey || null,
93
- remoteActionsEnv: args.env || base.remoteActionsEnv || "PROD",
94
108
  };
95
109
 
96
110
  for (const required of ["productId", "agentApiName"]) {
@@ -100,21 +114,24 @@ for (const required of ["productId", "agentApiName"]) {
100
114
  }
101
115
  }
102
116
 
103
- const sdkUrl = cfg.sdkUrl;
104
- const localActionsUrl = cfg.localActionsUrl || "http://localhost:3007/dist/index.js";
105
- const matches = target.hosts.map((h) => `*://${h}/*`);
106
- const requestDomains = [...new Set(target.hosts.map((h) => h.replace(/^\*\./, "")))];
107
- const scriptHost = new URL(sdkUrl).hostname;
117
+ console.log(`Resolved Foldspace target:`);
118
+ console.log(` target ${target.name}`);
119
+ console.log(` agent ${target.agentApiName} (product ${target.productId})`);
120
+ console.log(` hosts ${target.hosts.join(", ")}`);
121
+ console.log(` url ${startUrl}`);
108
122
 
109
- // ---------------------------------------------------------------- generate
123
+ if (args.flags.has("print")) process.exit(0);
124
+
125
+ // ---------------------------------------------------------------- launch
110
126
 
111
- fs.rmSync(extDir, { recursive: true, force: true });
112
- fs.mkdirSync(extDir, { recursive: true });
127
+ // Remove the obsolete generated application extension from older harness runs.
128
+ fs.rmSync(path.join(workDir, "extension"), {
129
+ recursive: true,
130
+ force: true,
131
+ });
113
132
 
114
133
  // A theme-only extension, regenerated each run so it survives `rm -rf
115
- // .foldspace-dev`. Purely cosmetic: it paints the dev browser Foldspace blue so
116
- // the window is never mistaken for the user's own Chrome. Chrome ignores
117
- // hand-edited theme keys in Preferences, but honours a theme extension.
134
+ // .foldspace-dev`. It is cosmetic and never loads Foldspace or action code.
118
135
  fs.rmSync(themeDir, { recursive: true, force: true });
119
136
  fs.mkdirSync(themeDir, { recursive: true });
120
137
  fs.writeFileSync(
@@ -142,262 +159,6 @@ fs.writeFileSync(
142
159
  }, null, 2),
143
160
  );
144
161
 
145
- const manifest = {
146
- manifest_version: 3,
147
- name: `Foldspace Dev — ${target.name}`,
148
- version: "0.0.1",
149
- description: `Local Foldspace injection for ${target.agentApiName}`,
150
- permissions: [
151
- "scripting",
152
- "activeTab",
153
- "tabs",
154
- "declarativeNetRequest",
155
- "declarativeNetRequestWithHostAccess",
156
- ],
157
- host_permissions: [...matches, "http://localhost/*", `https://${scriptHost}/*`],
158
- declarative_net_request: {
159
- rule_resources: [{ id: "csp_bypass_rules", enabled: true, path: "csp_rules.json" }],
160
- },
161
- ...(fs.existsSync(path.join(root, "extension", "logo.png"))
162
- ? { icons: { 16: "logo.png", 32: "logo.png", 48: "logo.png", 128: "logo.png" } }
163
- : {}),
164
- background: { service_worker: "background.js", type: "module" },
165
- web_accessible_resources: [{ resources: ["index.js"], matches }],
166
- content_scripts: [
167
- { matches, js: ["contentScript.js"], run_at: "document_start", all_frames: false },
168
- ],
169
- };
170
-
171
- // Strip CSP + frame guards on the target, and allow the target to pull from
172
- // localhost and the SDK host.
173
- const rules = [
174
- {
175
- id: 1,
176
- priority: 1,
177
- action: {
178
- type: "modifyHeaders",
179
- responseHeaders: [
180
- { operation: "remove", header: "content-security-policy" },
181
- { operation: "remove", header: "content-security-policy-report-only" },
182
- { operation: "remove", header: "x-frame-options" },
183
- ],
184
- },
185
- condition: {
186
- requestDomains,
187
- resourceTypes: [
188
- "main_frame", "sub_frame", "stylesheet", "script", "xmlhttprequest",
189
- "font", "object", "image", "media", "websocket", "other",
190
- ],
191
- },
192
- },
193
- {
194
- id: 2,
195
- priority: 1,
196
- action: {
197
- type: "modifyHeaders",
198
- responseHeaders: [
199
- { operation: "set", header: "access-control-allow-origin", value: new URL(startUrl).origin },
200
- { operation: "set", header: "access-control-allow-methods", value: "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH" },
201
- { operation: "set", header: "access-control-allow-headers", value: "*" },
202
- { operation: "set", header: "access-control-allow-credentials", value: "true" },
203
- ],
204
- },
205
- condition: {
206
- requestDomains: ["localhost", scriptHost],
207
- initiatorDomains: requestDomains,
208
- resourceTypes: ["script", "xmlhttprequest", "stylesheet", "font", "image", "media", "websocket", "other"],
209
- },
210
- },
211
- ];
212
-
213
- const indexJs = `// GENERATED by scripts/inject.mjs — do not edit. Target: ${target.name}
214
- const LOAD_LOCALLY = ${target.loadLocally};
215
- const SDK_URL = ${JSON.stringify(sdkUrl)};
216
- const LOCAL_ACTIONS_URL = ${JSON.stringify(localActionsUrl)};
217
- const NAMESPACE = "foldspace";
218
- const PRODUCT_ID = ${JSON.stringify(target.productId)};
219
- const AGENT_API_NAME = ${JSON.stringify(target.agentApiName)};
220
- const REMOTE_ACTIONS_ENV = ${JSON.stringify(target.remoteActionsEnv)};
221
- const MODE = ${JSON.stringify(target.mode)};
222
- const OVERRIDE_KEY = ${JSON.stringify(target.overrideKey)};
223
-
224
- function attachActions() {
225
- const actions = window.__FOLDSPACE_REMOTE_ACTIONS__;
226
- const agent = window.__FOLDSPACE_AGENT__;
227
- if (!actions || !agent) {
228
- console.error("[foldspace-dev] actions or agent missing");
229
- return;
230
- }
231
- delete window.__FOLDSPACE_REMOTE_ACTIONS__;
232
- agent.addActionHandlers(actions);
233
- console.log("[foldspace-dev] actions attached:", Object.keys(actions).length);
234
- }
235
-
236
- function loadRemoteActionsLocally() {
237
- const script = document.createElement("script");
238
- script.type = "text/javascript";
239
- script.async = true;
240
- script.src = LOCAL_ACTIONS_URL + "?t=" + Date.now();
241
- script.onload = attachActions;
242
- script.onerror = () => {
243
- // Direct fetch blocked — fall back to the service worker.
244
- window.addEventListener(
245
- "message",
246
- (event) => {
247
- if (event.data.type === "INJECT_FOLDSPACE_REMOTE_ACTIONS_RESPONSE" && event.data.ok) {
248
- attachActions();
249
- }
250
- },
251
- { once: true }
252
- );
253
- window.postMessage({ type: "INJECT_FOLDSPACE_REMOTE_ACTIONS", src: LOCAL_ACTIONS_URL }, "*");
254
- console.warn("[foldspace-dev] direct load failed, using background fetch. Is 'npm run dev' running?");
255
- };
256
- document.head.appendChild(script);
257
- }
258
-
259
- // The real SDK has loaded when it has replaced the stub with its own methods.
260
- // The stub only ever has q and k.
261
- function sdkLoaded() {
262
- return typeof window[NAMESPACE] === "function" && typeof window[NAMESPACE].agent === "function";
263
- }
264
-
265
- function appendSdkScript() {
266
- const w = window, d = document, n = NAMESPACE;
267
- const k = OVERRIDE_KEY || ("EU-" + PRODUCT_ID + "-1-1");
268
- w[n] = w[n] || function () { (w[n].q = w[n].q || []).push(arguments); };
269
- w.__FOLD_SPACE__ = n;
270
- w[n].k = k;
271
- if (d.querySelector("script[data-foldspace-sdk]")) return;
272
- // Wait for a real body. The SDK mounts its widget container on load, and
273
- // appendChild into a null body throws inside eucera.js — the widget then
274
- // never renders even though the SDK "loaded". Throwing here is deliberate:
275
- // the retry loop keeps trying until the document is ready for it.
276
- if (!d.body) throw new Error("document.body not ready");
277
- const s = d.createElement("script");
278
- s.async = true;
279
- s.src = SDK_URL + "?k=" + k;
280
- s.setAttribute("data-foldspace-sdk", "1");
281
- const h = d.getElementsByTagName("script")[0];
282
- if (h && h.parentNode) h.parentNode.insertBefore(s, h);
283
- else (d.head || d.documentElement).appendChild(s);
284
- }
285
-
286
- function initFoldspaceSDK() {
287
- // At document-start there may be no head and no documentElement yet, so the
288
- // first append can throw. Schedule the retry BEFORE trying, or a throw here
289
- // leaves the stub on window with nothing ever loading the real SDK — which
290
- // looks identical to a working page until you ask for the agent.
291
- let tries = 0;
292
- const retry = setInterval(() => {
293
- if (sdkLoaded() || ++tries > 60) { clearInterval(retry); return; }
294
- tryAppendSdkScript();
295
- }, 250);
296
- document.addEventListener("DOMContentLoaded", tryAppendSdkScript);
297
- tryAppendSdkScript();
298
- }
299
-
300
- function tryAppendSdkScript() {
301
- try { appendSdkScript(); } catch (e) { /* DOM not ready yet; the retry covers it */ }
302
- }
303
-
304
- function init() {
305
- const win = window;
306
- win.foldspace("when", "ready", async () => {
307
- const configuration = { enableDebugLogs: true };
308
-
309
- if (MODE === "EMBEDDED") {
310
- const container = document.createElement("div");
311
- container.id = "foldspace-container";
312
- document.body.appendChild(container);
313
- configuration.embeddedConfiguration = { container };
314
- }
315
-
316
- if (!LOAD_LOCALLY) {
317
- configuration.remoteActionsSettings = { enabled: true, environment: REMOTE_ACTIONS_ENV };
318
- console.log("[foldspace-dev] remote actions (" + REMOTE_ACTIONS_ENV + ")");
319
- } else {
320
- console.log("[foldspace-dev] local actions from " + LOCAL_ACTIONS_URL);
321
- }
322
-
323
- const agent = win.foldspace.agent({ apiName: AGENT_API_NAME, mode: MODE, configuration });
324
- win.__FOLDSPACE_AGENT__ = agent;
325
-
326
- agent.on("*", (e) => {
327
- if (e.eventName === "agent.ready" && LOAD_LOCALLY) {
328
- // attach.mjs injects the bundle at document-start, so the global is
329
- // usually already here — no network, no dev server, and it works
330
- // against a browser that cannot reach this machine at all.
331
- if (window.__FOLDSPACE_REMOTE_ACTIONS__) attachActions();
332
- else loadRemoteActionsLocally();
333
- }
334
- });
335
-
336
- // Ctrl+Shift+R reloads local actions without a page refresh.
337
- window.addEventListener("keydown", (e) => {
338
- if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "r" && LOAD_LOCALLY) {
339
- e.preventDefault();
340
- console.log("[foldspace-dev] reloading actions...");
341
- loadRemoteActionsLocally();
342
- }
343
- });
344
- });
345
- }
346
-
347
- if (window.top !== window.self) {
348
- // Third-party iframes (Zendesk, Chameleon, ...) must not run this.
349
- } else {
350
- // Guard on the loaded SDK, not on the stub: window.foldspace being truthy
351
- // only means some copy of this script got as far as defining the queue.
352
- if (!sdkLoaded()) initFoldspaceSDK();
353
- init();
354
- }
355
- function addDevBadge() {
356
- if (document.getElementById("foldspace-dev-badge")) return;
357
- const b = document.createElement("div");
358
- b.id = "foldspace-dev-badge";
359
- b.textContent = "FOLDSPACE DEV \u00b7 " + AGENT_API_NAME;
360
- b.style.cssText = [
361
- "position:fixed", "top:0", "left:0", "z-index:2147483647",
362
- "background:#3247F2", "color:#fff",
363
- "font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace",
364
- "letter-spacing:.12em", "padding:5px 10px",
365
- "border-bottom-right-radius:4px", "pointer-events:none",
366
- "box-shadow:0 1px 6px rgba(0,0,0,.35)",
367
- ].join(";");
368
- (document.body || document.documentElement).appendChild(b);
369
- }
370
- if (window.top === window.self) {
371
- // The host app is a SPA and will re-render over us, so keep re-asserting.
372
- const keepBadge = () => { if (document.body) addDevBadge(); };
373
- keepBadge();
374
- document.addEventListener("DOMContentLoaded", keepBadge);
375
- setInterval(keepBadge, 2000);
376
- }
377
-
378
- console.log("[foldspace-dev] injected", { agent: AGENT_API_NAME, product: PRODUCT_ID, mode: MODE });
379
- `;
380
-
381
- fs.writeFileSync(path.join(extDir, "manifest.json"), JSON.stringify(manifest, null, 2));
382
- fs.writeFileSync(path.join(extDir, "csp_rules.json"), JSON.stringify(rules, null, 2));
383
- fs.writeFileSync(path.join(extDir, "index.js"), indexJs);
384
- for (const f of ["contentScript.js", "background.js"]) {
385
- fs.copyFileSync(path.join(root, "extension", f), path.join(extDir, f));
386
- }
387
- const logoSrc = path.join(root, "extension", "logo.png");
388
- if (fs.existsSync(logoSrc)) fs.copyFileSync(logoSrc, path.join(extDir, "logo.png"));
389
-
390
- console.log(`Generated ${path.relative(root, extDir)}/`);
391
- console.log(` target ${target.name}`);
392
- console.log(` agent ${target.agentApiName} (product ${target.productId})`);
393
- console.log(` hosts ${target.hosts.join(", ")}`);
394
- console.log(` actions ${target.loadLocally ? localActionsUrl : "remote " + target.remoteActionsEnv}`);
395
- console.log(` url ${startUrl}`);
396
-
397
- if (args.flags.has("print")) process.exit(0);
398
-
399
- // ---------------------------------------------------------------- launch
400
-
401
162
  function findChrome() {
402
163
  const candidates =
403
164
  os.platform() === "darwin"
@@ -426,7 +187,18 @@ const debugPort = args.port || "9222";
426
187
  fs.mkdirSync(workDir, { recursive: true });
427
188
  fs.writeFileSync(
428
189
  path.join(workDir, "state.json"),
429
- JSON.stringify({ debugPort, target: targetName }, null, 2),
190
+ JSON.stringify(
191
+ {
192
+ debugPort,
193
+ target: targetName,
194
+ resolvedTarget: {
195
+ ...target,
196
+ sdkUrl: cfg.sdkUrl,
197
+ },
198
+ },
199
+ null,
200
+ 2,
201
+ ),
430
202
  );
431
203
 
432
204
  // Name the profile so this window is identifiable among other Chrome windows.
@@ -461,10 +233,10 @@ nameProfile();
461
233
  const chromeArgs = [
462
234
  `--user-data-dir=${profileDir}`,
463
235
  `--remote-debugging-port=${debugPort}`,
464
- // The dev extension plus a theme-only extension that paints the browser
465
- // Foldspace blue so this window is never mistaken for your own Chrome.
466
- `--load-extension=${extDir},${themeDir}`,
467
- `--disable-extensions-except=${extDir},${themeDir}`,
236
+ // The theme only paints the isolated window Foldspace blue. SDK and action
237
+ // code are loaded later by attach over CDP.
238
+ `--load-extension=${themeDir}`,
239
+ `--disable-extensions-except=${themeDir}`,
468
240
  // Chrome 136+ dropped --load-extension. Keep the legacy switch for older
469
241
  // builds, and enable the CDP Extensions domain so we can load it at runtime.
470
242
  "--disable-features=DisableLoadExtensionCommandLineSwitch",
@@ -483,11 +255,8 @@ child.unref();
483
255
 
484
256
  // --- theme -------------------------------------------------------------
485
257
  //
486
- // Chrome 151 ignores --load-extension entirely: launching with it produces a
487
- // profile with ZERO installed extensions. That is also why the generated dev
488
- // extension has never actually loaded — its content script, service worker and
489
- // CSP rules have all been inert. Verified 2026-08-25 by reading
490
- // Default/Preferences after launch.
258
+ // Chrome 151 ignores --load-extension, so load the optional cosmetic theme
259
+ // through CDP. No application extension is generated or loaded.
491
260
  //
492
261
  // The CDP Extensions domain still works (that is what
493
262
  // --enable-unsafe-extension-debugging is for), so load the theme that way.
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.2",
4
- "description": "Build, inject and verify Foldspace agent experiences against a live app.",
3
+ "version": "0.1.3",
4
+ "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "foldspace": "bin/cli.mjs",
8
8
  "harness": "bin/cli.mjs"
9
9
  },
10
+ "scripts": {
11
+ "test": "node --test test/*.test.mjs"
12
+ },
10
13
  "files": [
11
14
  "bin",
12
15
  "src",