@docsxai/engine 0.2.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.
Files changed (129) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +130 -0
  3. package/dist/auth/api-login.d.ts +69 -0
  4. package/dist/auth/api-login.js +95 -0
  5. package/dist/auth/browser-session.d.ts +28 -0
  6. package/dist/auth/browser-session.js +43 -0
  7. package/dist/auth/cookie-jar.d.ts +58 -0
  8. package/dist/auth/cookie-jar.js +212 -0
  9. package/dist/auth/email-otp.d.ts +210 -0
  10. package/dist/auth/email-otp.js +166 -0
  11. package/dist/auth/http-basic.d.ts +5 -0
  12. package/dist/auth/http-basic.js +17 -0
  13. package/dist/auth/index.d.ts +47 -0
  14. package/dist/auth/index.js +137 -0
  15. package/dist/auth/jwt-injection.d.ts +153 -0
  16. package/dist/auth/jwt-injection.js +136 -0
  17. package/dist/auth/manual-capture.d.ts +35 -0
  18. package/dist/auth/manual-capture.js +30 -0
  19. package/dist/auth/mtls.d.ts +15 -0
  20. package/dist/auth/mtls.js +53 -0
  21. package/dist/auth/pat-header.d.ts +19 -0
  22. package/dist/auth/pat-header.js +34 -0
  23. package/dist/auth/storage-state-cache.d.ts +38 -0
  24. package/dist/auth/storage-state-cache.js +143 -0
  25. package/dist/auth/test-backdoor.d.ts +25 -0
  26. package/dist/auth/test-backdoor.js +51 -0
  27. package/dist/auth/totp.d.ts +39 -0
  28. package/dist/auth/totp.js +108 -0
  29. package/dist/auth/types.d.ts +86 -0
  30. package/dist/auth/types.js +57 -0
  31. package/dist/auth/ui-form.d.ts +204 -0
  32. package/dist/auth/ui-form.js +153 -0
  33. package/dist/auth/webauthn.d.ts +88 -0
  34. package/dist/auth/webauthn.js +67 -0
  35. package/dist/auth.d.ts +1 -0
  36. package/dist/auth.js +3 -0
  37. package/dist/backend-client-contracts.d.ts +88 -0
  38. package/dist/backend-client-contracts.js +19 -0
  39. package/dist/backend-client-oauth-login.d.ts +7 -0
  40. package/dist/backend-client-oauth-login.js +90 -0
  41. package/dist/backend-client-state-cache.d.ts +73 -0
  42. package/dist/backend-client-state-cache.js +185 -0
  43. package/dist/backend-client-token.d.ts +18 -0
  44. package/dist/backend-client-token.js +94 -0
  45. package/dist/backend-client-transport.d.ts +66 -0
  46. package/dist/backend-client-transport.js +181 -0
  47. package/dist/backend-client.d.ts +5 -0
  48. package/dist/backend-client.js +18 -0
  49. package/dist/calibrate.d.ts +31 -0
  50. package/dist/calibrate.js +68 -0
  51. package/dist/cli-commands-authoring.d.ts +5 -0
  52. package/dist/cli-commands-authoring.js +403 -0
  53. package/dist/cli-commands-backend.d.ts +5 -0
  54. package/dist/cli-commands-backend.js +211 -0
  55. package/dist/cli-commands-docpack.d.ts +5 -0
  56. package/dist/cli-commands-docpack.js +280 -0
  57. package/dist/cli-commands-session.d.ts +4 -0
  58. package/dist/cli-commands-session.js +398 -0
  59. package/dist/cli-shared.d.ts +5 -0
  60. package/dist/cli-shared.js +45 -0
  61. package/dist/cli-usage.d.ts +1 -0
  62. package/dist/cli-usage.js +137 -0
  63. package/dist/cli.d.ts +2 -0
  64. package/dist/cli.js +77 -0
  65. package/dist/diagnose.d.ts +50 -0
  66. package/dist/diagnose.js +168 -0
  67. package/dist/diff-compute.d.ts +13 -0
  68. package/dist/diff-compute.js +378 -0
  69. package/dist/diff-report.d.ts +7 -0
  70. package/dist/diff-report.js +125 -0
  71. package/dist/diff-types.d.ts +125 -0
  72. package/dist/diff-types.js +15 -0
  73. package/dist/diff.d.ts +3 -0
  74. package/dist/diff.js +16 -0
  75. package/dist/doc-pack-io.d.ts +30 -0
  76. package/dist/doc-pack-io.js +182 -0
  77. package/dist/doc-pack.d.ts +1814 -0
  78. package/dist/doc-pack.js +328 -0
  79. package/dist/doctor-checks-plugins.d.ts +2 -0
  80. package/dist/doctor-checks-plugins.js +136 -0
  81. package/dist/doctor-checks.d.ts +56 -0
  82. package/dist/doctor-checks.js +367 -0
  83. package/dist/doctor.d.ts +7 -0
  84. package/dist/doctor.js +62 -0
  85. package/dist/export/adf.d.ts +57 -0
  86. package/dist/export/adf.js +323 -0
  87. package/dist/export/playwright-test.d.ts +26 -0
  88. package/dist/export/playwright-test.js +221 -0
  89. package/dist/flow-file.d.ts +21 -0
  90. package/dist/flow-file.js +180 -0
  91. package/dist/flow-lint.d.ts +24 -0
  92. package/dist/flow-lint.js +203 -0
  93. package/dist/flow-runtime.d.ts +113 -0
  94. package/dist/flow-runtime.js +273 -0
  95. package/dist/flow-tree.d.ts +19 -0
  96. package/dist/flow-tree.js +104 -0
  97. package/dist/index.d.ts +27 -0
  98. package/dist/index.js +31 -0
  99. package/dist/playwright-driver.d.ts +105 -0
  100. package/dist/playwright-driver.js +363 -0
  101. package/dist/playwright-instrumented-browser.d.ts +51 -0
  102. package/dist/playwright-instrumented-browser.js +189 -0
  103. package/dist/plugins/load.d.ts +22 -0
  104. package/dist/plugins/load.js +99 -0
  105. package/dist/plugins/lock.d.ts +40 -0
  106. package/dist/plugins/lock.js +122 -0
  107. package/dist/plugins/manifest.d.ts +70 -0
  108. package/dist/plugins/manifest.js +115 -0
  109. package/dist/plugins/plan.d.ts +51 -0
  110. package/dist/plugins/plan.js +279 -0
  111. package/dist/plugins/registry.d.ts +59 -0
  112. package/dist/plugins/registry.js +71 -0
  113. package/dist/plugins/runtime.d.ts +7 -0
  114. package/dist/plugins/runtime.js +27 -0
  115. package/dist/plugins/types.d.ts +58 -0
  116. package/dist/plugins/types.js +4 -0
  117. package/dist/plugins-cli.d.ts +1 -0
  118. package/dist/plugins-cli.js +191 -0
  119. package/dist/redact.d.ts +16 -0
  120. package/dist/redact.js +72 -0
  121. package/dist/style.d.ts +46 -0
  122. package/dist/style.js +151 -0
  123. package/dist/viewer-bin.d.ts +20 -0
  124. package/dist/viewer-bin.js +97 -0
  125. package/dist/workspace.d.ts +60 -0
  126. package/dist/workspace.js +172 -0
  127. package/dist/zip.d.ts +17 -0
  128. package/dist/zip.js +113 -0
  129. package/package.json +64 -0
@@ -0,0 +1,363 @@
1
+ // Playwright-backed BrowserDriver — the real execution-mode browser.
2
+ //
3
+ // Implements the {@link BrowserDriver} the flow-runtime is written against, over a Playwright
4
+ // `Page`. The runtime stays browser-agnostic; this is the one place that touches Playwright.
5
+ //
6
+ // Needs a browser binary. `playwright-core` ships the API but not the binaries — install one with
7
+ // `pnpm -C packages/engine exec playwright-core install chromium` (source checkout) or
8
+ // `npx playwright-core install chromium` (global/npx installs). For execution
9
+ // against an authed site, the context is created with a captured `storageState` (see the auth layer).
10
+ import { existsSync, promises as fs } from "node:fs";
11
+ import * as path from "node:path";
12
+ import { chromium, } from "playwright-core";
13
+ import { VIEWPORT_PRESETS, } from "./doc-pack.js";
14
+ import { applyRedactions } from "./redact.js";
15
+ import { resolveWorkspacePathReal } from "./workspace.js";
16
+ /**
17
+ * The cached Chromium binary path, or `undefined` when `playwright-core` has none installed.
18
+ *
19
+ * This is the single sanctioned entry point to `chromium.executablePath()`: keeping the
20
+ * availability probe here (the one module allowed to static-import playwright-core) means callers
21
+ * like `docsxai doctor` can ask "is a browser present?" without opening a second playwright-core
22
+ * import site. `executablePath()` only reports where the binary *would* live, so we existsSync it.
23
+ */
24
+ export function chromiumExecutablePath() {
25
+ const p = chromium.executablePath();
26
+ return p && existsSync(p) ? p : undefined;
27
+ }
28
+ /** Map an {@link EnvironmentSpec} to the Playwright context options it pins (clock excluded — that's a page-level install). */
29
+ export function environmentContextOptions(env) {
30
+ const viewport = typeof env.viewport === "string" ? VIEWPORT_PRESETS[env.viewport] : env.viewport;
31
+ return {
32
+ ...(env.locale ? { locale: env.locale } : {}),
33
+ ...(env.timezone ? { timezoneId: env.timezone } : {}),
34
+ ...(viewport ? { viewport } : {}),
35
+ ...(env.color_scheme ? { colorScheme: env.color_scheme } : {}),
36
+ ...(env.reduced_motion !== undefined
37
+ ? { reducedMotion: env.reduced_motion ? "reduce" : "no-preference" }
38
+ : {}),
39
+ };
40
+ }
41
+ async function installClock(page, env) {
42
+ if (!env?.clock)
43
+ return;
44
+ // `install({ time })` anchors the fake timers but still ticks with real time — millisecond
45
+ // jitter between runs would break byte-identical screenshots. `setFixedTime` is what actually
46
+ // freezes `Date`; timers keep running so timer-driven SPAs don't stall. The clock is
47
+ // context-wide in Playwright, so one install covers every page in the session.
48
+ await page.clock.install({ time: env.clock });
49
+ await page.clock.setFixedTime(env.clock);
50
+ }
51
+ /** Launch Chromium (or, with `connectOverCdp`, attach to a running one) and return a {@link PlaywrightSession}. */
52
+ export async function launchPlaywrightSession(opts = {}) {
53
+ if (opts.connectOverCdp) {
54
+ const browser = await chromium.connectOverCDP(opts.connectOverCdp);
55
+ const context = browser.contexts()[0] ?? (await browser.newContext());
56
+ const pages = context.pages();
57
+ const page = pages.find((p) => /^https?:/.test(p.url())) ?? pages[0] ?? (await context.newPage());
58
+ // The attached context is externally owned — context-level environment fields can't apply.
59
+ // The clock is a page-level install, so it still does.
60
+ const skipped = ["locale", "timezone", "viewport", "color_scheme", "reduced_motion"].filter((k) => opts.environment?.[k] !== undefined);
61
+ if (skipped.length || opts.contextOptions) {
62
+ process.stderr.write(`launchPlaywrightSession: attached over CDP — the browser context is externally owned; skipped: ${[
63
+ ...skipped.map((k) => `environment.${k}`),
64
+ ...(opts.contextOptions ? ["contextOptions"] : []),
65
+ ].join(", ")}\n`);
66
+ }
67
+ await installClock(page, opts.environment);
68
+ const driver = new PlaywrightDriver(page, opts.docPackRoot ?? ".");
69
+ return {
70
+ browser,
71
+ context,
72
+ page,
73
+ driver,
74
+ storageState: () => context.storageState(),
75
+ // Attached — don't close the browser; just let the connection drop.
76
+ close: async () => { },
77
+ };
78
+ }
79
+ const browser = await chromium.launch({
80
+ headless: !opts.headed,
81
+ ...(opts.chromiumArgs ? { args: opts.chromiumArgs } : {}),
82
+ });
83
+ // Playwright's `newContext({ storageState })` accepts the `{ cookies, origins }` object directly.
84
+ const context = await browser.newContext({
85
+ ...(opts.baseURL ? { baseURL: opts.baseURL } : {}),
86
+ ...(opts.storageState ? { storageState: opts.storageState } : {}),
87
+ ...(opts.ignoreHTTPSErrors ? { ignoreHTTPSErrors: true } : {}),
88
+ ...(opts.environment ? environmentContextOptions(opts.environment) : {}),
89
+ ...(opts.contextOptions ?? {}),
90
+ });
91
+ const page = await context.newPage();
92
+ await installClock(page, opts.environment);
93
+ const driver = new PlaywrightDriver(page, opts.docPackRoot ?? ".");
94
+ return {
95
+ browser,
96
+ context,
97
+ page,
98
+ driver,
99
+ storageState: () => context.storageState(),
100
+ close: async () => {
101
+ await context.close().catch(() => { });
102
+ await browser.close().catch(() => { });
103
+ },
104
+ };
105
+ }
106
+ export class PlaywrightDriver {
107
+ page;
108
+ docPackRoot;
109
+ constructor(page, docPackRoot = ".") {
110
+ this.page = page;
111
+ this.docPackRoot = docPackRoot;
112
+ }
113
+ goto(url) {
114
+ return this.page.goto(url).then(() => undefined);
115
+ }
116
+ click(selector) {
117
+ return this.page.click(selector);
118
+ }
119
+ fill(selector, value) {
120
+ return this.page.fill(selector, value);
121
+ }
122
+ upload(selector, filePath) {
123
+ return this.page.setInputFiles(selector, path.resolve(this.docPackRoot, filePath));
124
+ }
125
+ press(selector, key) {
126
+ return selector ? this.page.press(selector, key) : this.page.keyboard.press(key);
127
+ }
128
+ hover(selector) {
129
+ return this.page.hover(selector);
130
+ }
131
+ selectOption(selector, value) {
132
+ return this.page.selectOption(selector, value).then(() => undefined);
133
+ }
134
+ setChecked(selector, checked) {
135
+ return this.page.setChecked(selector, checked);
136
+ }
137
+ waitForNetworkIdle() {
138
+ return this.page.waitForLoadState("networkidle");
139
+ }
140
+ waitForLoad() {
141
+ return this.page.waitForLoadState("load");
142
+ }
143
+ async waitForElementStable(selector) {
144
+ // Stable = two consecutive identical bounding boxes (±0.5 px), polled every 100 ms. Best-effort
145
+ // with a 10 s budget: a perpetually-animating element proceeds after the budget rather than
146
+ // wedging the run — Playwright's per-action stability check still guards the action itself.
147
+ const loc = this.page.locator(selector).first();
148
+ const deadline = Date.now() + 10_000;
149
+ let prev = null;
150
+ while (Date.now() < deadline) {
151
+ const box = await loc.boundingBox({ timeout: 250 }).catch(() => null);
152
+ if (box &&
153
+ prev &&
154
+ Math.abs(box.x - prev.x) <= 0.5 &&
155
+ Math.abs(box.y - prev.y) <= 0.5 &&
156
+ Math.abs(box.width - prev.width) <= 0.5 &&
157
+ Math.abs(box.height - prev.height) <= 0.5) {
158
+ return;
159
+ }
160
+ prev = box;
161
+ await this.page.waitForTimeout(100);
162
+ }
163
+ }
164
+ waitForSelector(selector, timeoutMs) {
165
+ return this.page
166
+ .waitForSelector(selector, timeoutMs ? { timeout: timeoutMs } : {})
167
+ .then(() => undefined);
168
+ }
169
+ waitForTimeout(ms) {
170
+ return this.page.waitForTimeout(ms);
171
+ }
172
+ isVisible(selector) {
173
+ return this.page.locator(selector).isVisible();
174
+ }
175
+ urlMatches(pattern) {
176
+ return Promise.resolve(new RegExp(pattern).test(this.page.url()));
177
+ }
178
+ async textContains(selector, text) {
179
+ const t = (await this.page
180
+ .locator(selector)
181
+ .first()
182
+ .textContent()
183
+ .catch(() => null)) ?? "";
184
+ return t.includes(text);
185
+ }
186
+ currentUrl() {
187
+ return Promise.resolve(this.page.url());
188
+ }
189
+ count(selector) {
190
+ return this.page.locator(selector).count();
191
+ }
192
+ textOf(selector) {
193
+ return this.page
194
+ .locator(selector)
195
+ .first()
196
+ .textContent()
197
+ .catch(() => null);
198
+ }
199
+ async boundingBox(selector, timeoutMs) {
200
+ // Visible rect, not the element's own rect — intersect with each clipping ancestor (overflow != visible)
201
+ // and the viewport. Playwright's `Locator.boundingBox()` returns the element's geometry, which for an
202
+ // element inside a scroll container includes parts that are clipped *off-screen-within-the-scroller* —
203
+ // the screenshot doesn't show those, so a halo drawn from that rect overflows into the void.
204
+ const loc = this.page.locator(selector).first();
205
+ try {
206
+ await loc.waitFor({
207
+ state: "visible",
208
+ ...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
209
+ });
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ return loc
215
+ .evaluate((el) => {
216
+ const e = el;
217
+ const view = e.ownerDocument.defaultView;
218
+ const r = e.getBoundingClientRect();
219
+ let x = r.left, y = r.top, right = r.right, bottom = r.bottom;
220
+ let cur = e.parentElement;
221
+ while (cur) {
222
+ const cs = view.getComputedStyle(cur);
223
+ if (cs.overflow !== "visible" ||
224
+ cs.overflowX !== "visible" ||
225
+ cs.overflowY !== "visible") {
226
+ const cr = cur.getBoundingClientRect();
227
+ if (cr.left > x)
228
+ x = cr.left;
229
+ if (cr.top > y)
230
+ y = cr.top;
231
+ if (cr.right < right)
232
+ right = cr.right;
233
+ if (cr.bottom < bottom)
234
+ bottom = cr.bottom;
235
+ }
236
+ cur = cur.parentElement;
237
+ }
238
+ if (x < 0)
239
+ x = 0;
240
+ if (y < 0)
241
+ y = 0;
242
+ if (right > view.innerWidth)
243
+ right = view.innerWidth;
244
+ if (bottom > view.innerHeight)
245
+ bottom = view.innerHeight;
246
+ if (right <= x || bottom <= y)
247
+ return null;
248
+ // `getBoundingClientRect` + innerWidth/Height are CSS pixels; `page.screenshot()` produces a
249
+ // *device-pixel* image (CSS × devicePixelRatio — e.g. ~2× on a Retina/zoomed Chrome attached
250
+ // over CDP). The viewer scales the bbox by clientWidth/naturalWidth where naturalWidth is the
251
+ // PNG's device-pixel width, so the stored bbox must be in that same device-pixel space or the
252
+ // halo lands at CSS-coords-÷-dpr (badly mispositioned, and a wrong target rect throws the
253
+ // callout into a clamped sliver). Scale here. dpr === 1 (headless default) → no-op.
254
+ const dpr = view.devicePixelRatio || 1;
255
+ return { x: x * dpr, y: y * dpr, width: (right - x) * dpr, height: (bottom - y) * dpr };
256
+ })
257
+ .catch(() => null);
258
+ }
259
+ async screenshot(relPath, redactions = []) {
260
+ // relPath segments carry flow names + step ids from the flow-file — containment-checked
261
+ // (symlink-aware) against the doc-pack root before writing.
262
+ const abs = await resolveWorkspacePathReal(this.docPackRoot, relPath);
263
+ await fs.mkdir(path.dirname(abs), { recursive: true });
264
+ // `animations: "disabled"` fast-forwards finite CSS animations/transitions to their end state
265
+ // and cancels infinite ones, so an element transitioning in (opacity/transform) is captured
266
+ // fully settled instead of mid-fade. `caret: "hide"` keeps a blinking text caret out of shots.
267
+ const shotOptions = { animations: "disabled", caret: "hide" };
268
+ if (redactions.length === 0) {
269
+ await this.page.screenshot({ path: abs, ...shotOptions });
270
+ return;
271
+ }
272
+ // Redacted path: capture to a buffer, mask, then write — the unredacted bytes never hit disk.
273
+ const boxes = await this.resolveRedactionBoxes(redactions);
274
+ const png = await this.page.screenshot(shotOptions);
275
+ await fs.writeFile(abs, applyRedactions(png, boxes));
276
+ }
277
+ /**
278
+ * Resolve redactions to pixel rects in the screenshot's device-pixel space: selector entries via
279
+ * {@link boundingBox} (already dpr-scaled), fixed regions scaled by the page's devicePixelRatio.
280
+ * A selector matching nothing on-page is skipped with a warning — an absent element is vacuously
281
+ * redacted; halting would punish flows for UI that legitimately isn't there.
282
+ */
283
+ async resolveRedactionBoxes(redactions) {
284
+ const boxes = [];
285
+ let dpr;
286
+ for (const r of redactions) {
287
+ if ("selector" in r) {
288
+ const bbox = await this.boundingBox(r.selector, 1000);
289
+ if (!bbox || bbox.width <= 0 || bbox.height <= 0) {
290
+ process.stderr.write(`screenshot: redaction selector ${r.selector} has no visible box — skipped\n`);
291
+ continue;
292
+ }
293
+ boxes.push({ ...bbox, style: r.style });
294
+ }
295
+ else {
296
+ dpr ??= await this.page.evaluate(() => globalThis.devicePixelRatio || 1);
297
+ boxes.push({
298
+ x: r.region.x * dpr,
299
+ y: r.region.y * dpr,
300
+ width: r.region.width * dpr,
301
+ height: r.region.height * dpr,
302
+ style: r.style,
303
+ });
304
+ }
305
+ }
306
+ return boxes;
307
+ }
308
+ async actionable(selector, timeoutMs = 300) {
309
+ const loc = this.page.locator(selector);
310
+ let count;
311
+ try {
312
+ count = await loc.count();
313
+ }
314
+ catch {
315
+ return "not-found";
316
+ }
317
+ if (count === 0)
318
+ return "not-found";
319
+ if (count > 1)
320
+ return "multiple-matches";
321
+ const first = loc.first();
322
+ try {
323
+ const attached = await first.evaluate((el) => el.isConnected ?? false);
324
+ if (!attached)
325
+ return "detached";
326
+ }
327
+ catch {
328
+ return "detached";
329
+ }
330
+ const visible = await first.isVisible().catch(() => false);
331
+ if (!visible)
332
+ return "not-visible";
333
+ // Off-screen check via the visible-rect bbox we already compute (intersects with clipping ancestors
334
+ // + the viewport). `null` from bbox here means fully clipped — caller's most useful framing is
335
+ // "off-screen" rather than "not-visible" since CSS-wise the element IS visible.
336
+ const bbox = await this.boundingBox(selector, timeoutMs).catch(() => null);
337
+ if (!bbox)
338
+ return "off-screen";
339
+ const enabled = await first.isEnabled().catch(() => true);
340
+ if (!enabled)
341
+ return "disabled";
342
+ // "Covered" — hit-test the bbox center; if elementFromPoint returns something that isn't this
343
+ // element or a descendant of it, another layer is on top.
344
+ try {
345
+ const covered = await first.evaluate((el) => {
346
+ const e = el;
347
+ const r = e.getBoundingClientRect();
348
+ const cx = r.left + r.width / 2;
349
+ const cy = r.top + r.height / 2;
350
+ const top = e.ownerDocument.elementFromPoint(cx, cy);
351
+ if (!top || top === el)
352
+ return false;
353
+ return !e.contains(top);
354
+ });
355
+ if (covered)
356
+ return "covered";
357
+ }
358
+ catch {
359
+ // ignore — covered check is best-effort
360
+ }
361
+ return "actionable";
362
+ }
363
+ }
@@ -0,0 +1,51 @@
1
+ import { type CaptureTrigger, type InstrumentedBrowser, type StorageState } from "./auth.js";
2
+ /** Security-lowered Chromium args (see the module note). */
3
+ export declare const SECURITY_LOWERED_ARGS: readonly string[];
4
+ export interface PlaywrightInstrumentedBrowserOptions {
5
+ /** Headed by default (the human logs in there). Set true to run headless — for automated tests only. (Ignored when attaching.) */
6
+ headless?: boolean;
7
+ /** Accept self-signed / invalid TLS certs (e.g. the target app's local HTTPS dev cert). Default: false. (Ignored when attaching.) */
8
+ ignoreHTTPSErrors?: boolean;
9
+ /** Extra Chromium args appended after {@link SECURITY_LOWERED_ARGS}. (Ignored when attaching.) */
10
+ extraArgs?: string[];
11
+ /**
12
+ * If set, launch with a **persistent profile** at this directory (a `userDataDir`) — cookies, localStorage and
13
+ * login state survive between captures, so re-running `capture-auth` reuses the login instead of a fresh Chrome.
14
+ * `capture-auth` defaults this to `<workspace>/.auth/chrome-profile/` (gitignored, never leaves the machine).
15
+ * Mutually exclusive with `connectOverCdp` (attach short-circuits launching).
16
+ */
17
+ profileDir?: string;
18
+ /**
19
+ * If set, **attach to an already-running Chrome** at this CDP endpoint (e.g. `http://localhost:9222`) instead
20
+ * of launching a fresh one. Use this to capture from the *same* Chrome the engineer is already logged into —
21
+ * and that Claude in Chrome is driving for discovery — so they don't log in twice. Start that Chrome with
22
+ * `--remote-debugging-port=9222 --disable-web-security --disable-features=IsolateOrigins,site-per-process --user-data-dir=<dir>`.
23
+ * docsxai will **not** close it on `close()` — it's the engineer's session.
24
+ */
25
+ connectOverCdp?: string;
26
+ }
27
+ /**
28
+ * The injected page-side helper. Self-cleans when the Node-side binding is gone (which happens
29
+ * when the Playwright client detaches — e.g. `capture-auth` finishes and disconnects from a
30
+ * shared `--cdp` Chrome that stays alive). Without this guard, a stale `__docsxai.capture()`
31
+ * call after detach throws `Function "__docsxai_capture" is not exposed` in the page console —
32
+ * cosmetic, but alarming to operators sharing the Chrome with another tool.
33
+ *
34
+ * Exported for unit-testing (run the returned script in a `vm` sandbox against a mock window).
35
+ */
36
+ export declare function helperScript(trigger: CaptureTrigger): string;
37
+ export declare class PlaywrightInstrumentedBrowser implements InstrumentedBrowser {
38
+ private readonly opts;
39
+ private browser?;
40
+ private context?;
41
+ private page?;
42
+ private attached;
43
+ private persistent;
44
+ private captured;
45
+ private resolveCaptured;
46
+ constructor(opts?: PlaywrightInstrumentedBrowserOptions);
47
+ open(baseURL: string): Promise<void>;
48
+ waitForCapture(trigger: CaptureTrigger): Promise<void>;
49
+ storageState(): Promise<StorageState>;
50
+ close(): Promise<void>;
51
+ }
@@ -0,0 +1,189 @@
1
+ // Playwright-backed InstrumentedBrowser — the security-lowered, instrumented Chrome `manual-capture` drives.
2
+ //
3
+ // The plugin spawns this; the engineer logs into the target site interactively (Azure AD SSO, MFA,
4
+ // conditional access — anything a human can click through); a console call `window.__docsxai.capture()`
5
+ // (or an injected on-page button) snapshots `storageState`. `--disable-web-security` etc. let the injected
6
+ // helper work across the SSO-redirect origins and relaxed CSP. Headed by default — the human needs to see it.
7
+ import { chromium } from "playwright-core";
8
+ /** Security-lowered Chromium args (see the module note). */
9
+ export const SECURITY_LOWERED_ARGS = [
10
+ "--disable-web-security",
11
+ "--disable-features=IsolateOrigins,site-per-process",
12
+ "--disable-site-isolation-trials",
13
+ ];
14
+ const CAPTURE_BINDING = "__docsxai_capture";
15
+ /**
16
+ * The injected page-side helper. Self-cleans when the Node-side binding is gone (which happens
17
+ * when the Playwright client detaches — e.g. `capture-auth` finishes and disconnects from a
18
+ * shared `--cdp` Chrome that stays alive). Without this guard, a stale `__docsxai.capture()`
19
+ * call after detach throws `Function "__docsxai_capture" is not exposed` in the page console —
20
+ * cosmetic, but alarming to operators sharing the Chrome with another tool.
21
+ *
22
+ * Exported for unit-testing (run the returned script in a `vm` sandbox against a mock window).
23
+ */
24
+ export function helperScript(trigger) {
25
+ const button = trigger === "button"
26
+ ? `if (!document.getElementById("__docsxai_btn")) {
27
+ var b = document.createElement("button");
28
+ b.id = "__docsxai_btn"; b.textContent = "\\u2713 Capture session for docsxai";
29
+ b.style.cssText = "position:fixed;z-index:2147483647;right:12px;bottom:12px;padding:10px 14px;background:#1c1c1c;color:#fff;border:0;border-radius:8px;font:14px system-ui;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.3)";
30
+ b.onclick = function () { window.__docsxai.capture(); };
31
+ (document.body || document.documentElement).appendChild(b);
32
+ }`
33
+ : "";
34
+ return `(function () {
35
+ window.__docsxai = window.__docsxai || {};
36
+ window.__docsxai.capture = function () {
37
+ if (typeof window.${CAPTURE_BINDING} !== "function") {
38
+ // Backing binding is gone (the Playwright client that injected this helper has detached).
39
+ // Self-clean: drop the button + the helper, log a friendly note. Reload or re-run
40
+ // \`docsxai capture-auth\` to install a fresh helper.
41
+ var staleBtn = document.getElementById("__docsxai_btn");
42
+ if (staleBtn) staleBtn.remove();
43
+ delete window.__docsxai.capture;
44
+ if (typeof console !== "undefined" && console.info) {
45
+ console.info("[docsxai] capture helper detached; reload the page or re-run \`docsxai capture-auth\` to re-install.");
46
+ }
47
+ return undefined;
48
+ }
49
+ return window.${CAPTURE_BINDING}();
50
+ };
51
+ ${button}
52
+ })();`;
53
+ }
54
+ export class PlaywrightInstrumentedBrowser {
55
+ opts;
56
+ browser;
57
+ context;
58
+ page;
59
+ attached = false;
60
+ persistent = false;
61
+ captured = Promise.resolve();
62
+ resolveCaptured = () => { };
63
+ constructor(opts = {}) {
64
+ this.opts = opts;
65
+ }
66
+ async open(baseURL) {
67
+ this.captured = new Promise((resolve) => {
68
+ this.resolveCaptured = resolve;
69
+ });
70
+ if (this.opts.connectOverCdp) {
71
+ // Attach to an already-running Chrome — the engineer's, already logged in (and being driven by Claude
72
+ // in Chrome for discovery). One login, one browser. We never close it.
73
+ this.attached = true;
74
+ this.browser = await chromium.connectOverCDP(this.opts.connectOverCdp);
75
+ this.context = this.browser.contexts()[0] ?? (await this.browser.newContext());
76
+ await this.context.exposeFunction(CAPTURE_BINDING, () => {
77
+ this.resolveCaptured();
78
+ });
79
+ const pages = this.context.pages();
80
+ this.page =
81
+ pages.find((p) => /^https?:/.test(p.url())) ?? pages[0] ?? (await this.context.newPage());
82
+ if (baseURL && this.page.url() === "about:blank")
83
+ await this.page.goto(baseURL).catch(() => undefined);
84
+ return;
85
+ }
86
+ this.attached = false;
87
+ if (this.opts.profileDir) {
88
+ // Launch with a persistent profile — cookies/login survive between captures.
89
+ this.persistent = true;
90
+ this.context = await chromium.launchPersistentContext(this.opts.profileDir, {
91
+ headless: this.opts.headless ?? false,
92
+ args: [...SECURITY_LOWERED_ARGS, ...(this.opts.extraArgs ?? [])],
93
+ ...(this.opts.ignoreHTTPSErrors ? { ignoreHTTPSErrors: true } : {}),
94
+ ...(baseURL ? { baseURL } : {}),
95
+ });
96
+ this.browser = this.context.browser() ?? undefined;
97
+ await this.context.exposeFunction(CAPTURE_BINDING, () => {
98
+ this.resolveCaptured();
99
+ });
100
+ this.page = this.context.pages()[0] ?? (await this.context.newPage());
101
+ await this.page.goto(baseURL);
102
+ return;
103
+ }
104
+ // Launch a fresh, ephemeral, security-lowered, instrumented Chrome.
105
+ this.persistent = false;
106
+ this.browser = await chromium.launch({
107
+ headless: this.opts.headless ?? false,
108
+ args: [...SECURITY_LOWERED_ARGS, ...(this.opts.extraArgs ?? [])],
109
+ });
110
+ this.context = await this.browser.newContext({
111
+ baseURL,
112
+ ...(this.opts.ignoreHTTPSErrors ? { ignoreHTTPSErrors: true } : {}),
113
+ });
114
+ // Node-side capture trigger; available on every page in the context (survives navigations).
115
+ await this.context.exposeFunction(CAPTURE_BINDING, () => {
116
+ this.resolveCaptured();
117
+ });
118
+ this.page = await this.context.newPage();
119
+ await this.page.goto(baseURL);
120
+ }
121
+ async waitForCapture(trigger) {
122
+ if (!this.context || !this.page)
123
+ throw new Error("open() must be called before waitForCapture()");
124
+ const script = helperScript(trigger);
125
+ await this.context.addInitScript(script); // future documents (incl. post-SSO-redirect)
126
+ await this.page.evaluate(script).catch(() => undefined); // the already-loaded document too
127
+ await this.captured;
128
+ }
129
+ async storageState() {
130
+ if (!this.context)
131
+ throw new Error("storageState() called before open()");
132
+ const state = await this.context.storageState();
133
+ for (const page of this.context.pages()) {
134
+ try {
135
+ const u = page.url();
136
+ if (!/^https?:/.test(u))
137
+ continue;
138
+ const origin = new URL(u).origin;
139
+ const evalAsTyped = page.evaluate.bind(page);
140
+ const pairs = await evalAsTyped(() => {
141
+ const ls = globalThis.localStorage;
142
+ const o = [];
143
+ for (let i = 0; i < ls.length; i++) {
144
+ const k = ls.key(i);
145
+ if (k != null)
146
+ o.push([k, ls.getItem(k) ?? ""]);
147
+ }
148
+ return o;
149
+ });
150
+ if (!Array.isArray(pairs) || pairs.length === 0)
151
+ continue;
152
+ const items = pairs.map(([name, value]) => ({ name, value }));
153
+ const existing = state.origins.find((o) => o.origin === origin);
154
+ if (existing) {
155
+ const known = new Set(existing.localStorage.map((e) => e.name));
156
+ for (const it of items)
157
+ if (!known.has(it.name))
158
+ existing.localStorage.push(it);
159
+ }
160
+ else {
161
+ state.origins.push({ origin, localStorage: items });
162
+ }
163
+ }
164
+ catch {
165
+ // skip a page we can't read (cross-origin, closed, etc.)
166
+ }
167
+ }
168
+ return state;
169
+ }
170
+ async close() {
171
+ if (this.attached) {
172
+ // Don't touch the engineer's Chrome — just detach. The injected `__docsxai` helper detects
173
+ // the backing binding is gone on its next invocation, removes its injected button if any,
174
+ // logs a friendly info message, and self-deletes from `window.__docsxai.capture`. Safe to
175
+ // share the Chrome with other automation clients.
176
+ this.context = undefined;
177
+ this.browser = undefined;
178
+ this.page = undefined;
179
+ return;
180
+ }
181
+ // For a persistent context, `context.close()` flushes the profile to disk and closes the browser.
182
+ await this.context?.close().catch(() => undefined);
183
+ if (!this.persistent)
184
+ await this.browser?.close().catch(() => undefined);
185
+ this.context = undefined;
186
+ this.browser = undefined;
187
+ this.page = undefined;
188
+ }
189
+ }
@@ -0,0 +1,22 @@
1
+ import type { PluginKind } from "./manifest.js";
2
+ import { PluginRegistry } from "./registry.js";
3
+ import type { AuthStrategyPlugin, PluginLintRule, PluginLogger, PublisherPlugin, RendererPlugin } from "./types.js";
4
+ import { type PluginPlan, type ResolvePluginsOptions } from "./plan.js";
5
+ /** What a plugin's `register(api)` receives. Registered names are auto-prefixed `<ns>:<name>`. */
6
+ export interface PluginRegisterApi {
7
+ readonly namespace: string;
8
+ readonly declaredKinds: ReadonlyArray<PluginKind>;
9
+ readonly declaredCapabilities: ReadonlyArray<string>;
10
+ registerPublisher(name: string, impl: PublisherPlugin): void;
11
+ registerRenderer(name: string, impl: RendererPlugin): void;
12
+ registerLintRules(name: string, rules: PluginLintRule[]): void;
13
+ registerAuthStrategy(name: string, impl: AuthStrategyPlugin): void;
14
+ readonly log: PluginLogger;
15
+ /** Workspace-contained path resolution — the only filesystem root a plugin may write under. */
16
+ workspacePath(...segments: string[]): string;
17
+ }
18
+ /**
19
+ * Commit the plan's disabled records, then import + register the surviving plugins in load order.
20
+ * Every failure is a status, never a throw. This is the only place plugin code is imported.
21
+ */
22
+ export declare function loadPlugins(opts: ResolvePluginsOptions, plan: PluginPlan): Promise<PluginRegistry>;