@broberg/lens-daemon 0.1.31 → 0.1.34
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 +2 -2
- package/src/browser.ts +98 -1
- package/src/capture-session.ts +11 -5
- package/src/daemon-routes.ts +8 -0
- package/src/flow.ts +67 -8
- package/src/mcp.ts +15 -1
- package/src/profiles.ts +24 -1
- package/src/service.ts +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/lens-daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
4
4
|
"description": "The Lens module of the cardmem daemon (flows, critic, coverage, capture sessions, the /lens/* routes and the cardmem-lens MCP) as a package. The daemon mounts createLensRouter(deps) + createLensMcpRouter(deps). Bun runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"pixelmatch": "^6.0.0",
|
|
13
13
|
"playwright": "1.61.1",
|
|
14
14
|
"pngjs": "^7.0.0",
|
|
15
|
-
"sharp": "^0.
|
|
15
|
+
"sharp": "^0.35.5",
|
|
16
16
|
"turndown": "^7.2.4",
|
|
17
17
|
"zod": "^3.23.8",
|
|
18
18
|
"@broberg/lens-engine": "0.14.1",
|
package/src/browser.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// auth (F074.4) and concurrent captures never bleed into each other. The
|
|
11
11
|
// context + page are always closed in a finally, even on capture error.
|
|
12
12
|
|
|
13
|
-
import { claimProfile, profileDir } from './profiles.ts';
|
|
13
|
+
import { bindProfileChannel, claimProfile, profileDir, type ProfileChannel } from './profiles.ts';
|
|
14
14
|
import {
|
|
15
15
|
chromium,
|
|
16
16
|
webkit,
|
|
@@ -228,6 +228,19 @@ export interface WithPageOptions {
|
|
|
228
228
|
* This is a SECOND launch path, not a flag on the warm browser: extensions
|
|
229
229
|
* need launchPersistentContext with a real user-data dir on disk. */
|
|
230
230
|
extension?: { path: string; /** F009.132 — a kept profile instead of a temp dir. */ profile?: string };
|
|
231
|
+
/** F009.136 — run in a named browser BUILD ('chrome' = the installed Google
|
|
232
|
+
* Chrome) with its own user-data dir: a kept profile (where a site's login
|
|
233
|
+
* lives) or a temp one. Google refuses a sign-in in Playwright's Chromium, so
|
|
234
|
+
* YouTube Studio / Postmaster / the Web Store need this. Not with `extension`. */
|
|
235
|
+
browser?: RunBrowser;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface RunBrowser {
|
|
239
|
+
channel: ProfileChannel;
|
|
240
|
+
profile?: string;
|
|
241
|
+
/** Open a window instead of running headless — for a site that refuses even
|
|
242
|
+
* de-flagged headless Chrome. Default false. */
|
|
243
|
+
visible?: boolean;
|
|
231
244
|
}
|
|
232
245
|
|
|
233
246
|
/** Exported for test: F074.57's central promise to everyone who did NOT ask for
|
|
@@ -337,6 +350,7 @@ async function launchWithExtension(
|
|
|
337
350
|
const profile = opts.extension?.profile;
|
|
338
351
|
for (let attempt = 1; attempt <= EXTENSION_LAUNCH_ATTEMPTS && !context; attempt++) {
|
|
339
352
|
// F009.132 — a named profile is KEPT: that is where a site's login lives.
|
|
353
|
+
if (profile) bindProfileChannel(profile, EXTENSION_CHANNEL);
|
|
340
354
|
userDataDir = profile ? profileDir(profile) : await mkdtemp(join(tmpdir(), 'lens-ext-'));
|
|
341
355
|
try {
|
|
342
356
|
context = await chromium.launchPersistentContext(userDataDir, {
|
|
@@ -382,6 +396,60 @@ async function launchWithExtension(
|
|
|
382
396
|
return { context, extension: { id: new URL(bg).host, path: extPath }, userDataDir };
|
|
383
397
|
}
|
|
384
398
|
|
|
399
|
+
/** F009.136 — the same de-flagging the capture-session login window uses: no
|
|
400
|
+
* --enable-automation (the "controlled by automated software" bar and the
|
|
401
|
+
* navigator.webdriver flag) and AutomationControlled off. Exported so the build
|
|
402
|
+
* and the flags are asserted, not assumed. */
|
|
403
|
+
export function runBrowserLaunchOptions(b: RunBrowser) {
|
|
404
|
+
return {
|
|
405
|
+
channel: b.channel,
|
|
406
|
+
headless: b.visible !== true,
|
|
407
|
+
ignoreDefaultArgs: ['--enable-automation'],
|
|
408
|
+
args: ['--disable-blink-features=AutomationControlled', '--disable-dev-shm-usage'],
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Headless Chrome announces itself as "HeadlessChrome/<v>" in its user agent, the
|
|
413
|
+
// first thing a sign-in page checks. The real agent is read ONCE per build from the
|
|
414
|
+
// browser itself — never a hardcoded version string, which would be wrong after the
|
|
415
|
+
// next Chrome update.
|
|
416
|
+
const headlessAgents = new Map<ProfileChannel, Promise<string>>();
|
|
417
|
+
function realUserAgent(channel: ProfileChannel): Promise<string> {
|
|
418
|
+
let ua = headlessAgents.get(channel);
|
|
419
|
+
if (!ua) {
|
|
420
|
+
ua = (async () => {
|
|
421
|
+
const b = await chromium.launch({ channel, headless: true });
|
|
422
|
+
try {
|
|
423
|
+
const page = await b.newPage();
|
|
424
|
+
return (await page.evaluate(() => navigator.userAgent)).replace('HeadlessChrome/', 'Chrome/');
|
|
425
|
+
} finally {
|
|
426
|
+
await b.close().catch(() => {});
|
|
427
|
+
}
|
|
428
|
+
})();
|
|
429
|
+
ua.catch(() => headlessAgents.delete(channel));
|
|
430
|
+
headlessAgents.set(channel, ua);
|
|
431
|
+
}
|
|
432
|
+
return ua;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function launchRunBrowser(opts: WithPageOptions, b: RunBrowser): Promise<{ context: BrowserContext; userDataDir: string }> {
|
|
436
|
+
if (b.profile) bindProfileChannel(b.profile, b.channel);
|
|
437
|
+
const userDataDir = b.profile ? profileDir(b.profile) : await mkdtemp(join(tmpdir(), 'lens-run-'));
|
|
438
|
+
const userAgent = opts.userAgent ?? (b.visible ? undefined : await realUserAgent(b.channel));
|
|
439
|
+
try {
|
|
440
|
+
const context = await chromium.launchPersistentContext(userDataDir, {
|
|
441
|
+
...contextOptions({ ...opts, ...(userAgent ? { userAgent } : {}) }),
|
|
442
|
+
...runBrowserLaunchOptions(b),
|
|
443
|
+
timeout: EXTENSION_LAUNCH_TIMEOUT_MS,
|
|
444
|
+
});
|
|
445
|
+
return { context, userDataDir };
|
|
446
|
+
} catch (err) {
|
|
447
|
+
if (!b.profile) await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
|
|
448
|
+
const msg = err instanceof Error ? err.message.split('\n')[0]! : String(err);
|
|
449
|
+
throw new Error(`browser "${b.channel}" did not start: ${msg}${b.channel === 'chrome' ? ' — is Google Chrome installed on the daemon host?' : ''}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
385
453
|
export async function withPage<T>(
|
|
386
454
|
opts: WithPageOptions,
|
|
387
455
|
fn: (page: Page, context: BrowserContext, extension?: LoadedExtension) => Promise<T>,
|
|
@@ -431,6 +499,35 @@ export async function withPage<T>(
|
|
|
431
499
|
}
|
|
432
500
|
}
|
|
433
501
|
|
|
502
|
+
// F009.136 — a named build with its own user-data dir: its own launch and
|
|
503
|
+
// lifecycle, like the extension path, never the warm browser.
|
|
504
|
+
if (opts.browser) {
|
|
505
|
+
if (engine !== 'chromium') {
|
|
506
|
+
throw new Error(`browser.channel runs a Chromium-family build; engine "${engine}" cannot. Drop the engine option.`);
|
|
507
|
+
}
|
|
508
|
+
const b = opts.browser;
|
|
509
|
+
const release = b.profile ? claimProfile(b.profile) : () => {};
|
|
510
|
+
let launched: Awaited<ReturnType<typeof launchRunBrowser>>;
|
|
511
|
+
try {
|
|
512
|
+
launched = await launchRunBrowser(opts, b);
|
|
513
|
+
} catch (err) {
|
|
514
|
+
release();
|
|
515
|
+
throw err;
|
|
516
|
+
}
|
|
517
|
+
const { context, userDataDir } = launched;
|
|
518
|
+
try {
|
|
519
|
+
await context.addInitScript(READINESS_INIT_SCRIPT);
|
|
520
|
+
if (opts.prepareContext) await opts.prepareContext(context);
|
|
521
|
+
await applyStorageSeed(context, opts.seedStorage ?? null);
|
|
522
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
523
|
+
return await fn(page, context);
|
|
524
|
+
} finally {
|
|
525
|
+
await withDeadline(context.close(), 5_000, 'browser context close').catch(() => {});
|
|
526
|
+
if (!b.profile) await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
|
|
527
|
+
release();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
434
531
|
const makeContext = async (): Promise<BrowserContext> => {
|
|
435
532
|
const browser = await withDeadline(getBrowser(engine), WEDGE_TIMEOUT_MS, 'browser launch');
|
|
436
533
|
return withDeadline(
|
package/src/capture-session.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// are confined to ~/.lens/state so a bad state_path can't clobber an arbitrary
|
|
19
19
|
// file. The endpoint is loopback-only (127.0.0.1) behind the daemon's auth.
|
|
20
20
|
|
|
21
|
-
import { claimProfile, profileDir } from './profiles.ts';
|
|
21
|
+
import { bindProfileChannel, claimProfile, profileDir, type ProfileChannel } from './profiles.ts';
|
|
22
22
|
import { EXTENSION_CHANNEL } from './browser.ts';
|
|
23
23
|
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
|
24
24
|
import { z } from 'zod';
|
|
@@ -62,10 +62,12 @@ export interface FinishableSession {
|
|
|
62
62
|
/** F009.132 — a login for a kept profile must happen in the SAME browser build
|
|
63
63
|
* the extension runs use (Google binds its session to the browser), visibly, so
|
|
64
64
|
* a human can type the password and 2FA. Exported so the build is asserted. */
|
|
65
|
-
export function profileLaunchOptions() {
|
|
65
|
+
export function profileLaunchOptions(channel: ProfileChannel = EXTENSION_CHANNEL) {
|
|
66
66
|
return {
|
|
67
67
|
headless: false,
|
|
68
|
-
|
|
68
|
+
// F009.136 — 'chrome' when the runs that will use this profile run in Google
|
|
69
|
+
// Chrome; the default stays the extension runs' build.
|
|
70
|
+
channel,
|
|
69
71
|
ignoreDefaultArgs: ['--enable-automation'],
|
|
70
72
|
args: ['--disable-blink-features=AutomationControlled'],
|
|
71
73
|
};
|
|
@@ -376,6 +378,8 @@ export async function startCaptureSession(input: {
|
|
|
376
378
|
loadStatePath?: string;
|
|
377
379
|
/** F009.132 — log in INSIDE the kept profile ~/.lens/profiles/<name>. */
|
|
378
380
|
profile?: string;
|
|
381
|
+
/** F009.136 — the build the profile's runs use (default the extension build). */
|
|
382
|
+
channel?: ProfileChannel;
|
|
379
383
|
}): Promise<{
|
|
380
384
|
sessionId: string;
|
|
381
385
|
url: string;
|
|
@@ -407,10 +411,12 @@ export async function startCaptureSession(input: {
|
|
|
407
411
|
if (input.profile) {
|
|
408
412
|
// F009.132 — the login lands in the profile itself; the state file written at
|
|
409
413
|
// finish is a copy for runs that do not use the profile.
|
|
414
|
+
const channel = input.channel ?? EXTENSION_CHANNEL;
|
|
415
|
+
bindProfileChannel(input.profile, channel);
|
|
410
416
|
const dir = profileDir(input.profile);
|
|
411
417
|
releaseProfile = claimProfile(input.profile);
|
|
412
418
|
try {
|
|
413
|
-
context = await chromium.launchPersistentContext(dir, profileLaunchOptions());
|
|
419
|
+
context = await chromium.launchPersistentContext(dir, profileLaunchOptions(channel));
|
|
414
420
|
} catch (err) {
|
|
415
421
|
releaseProfile();
|
|
416
422
|
throw err;
|
|
@@ -419,7 +425,7 @@ export async function startCaptureSession(input: {
|
|
|
419
425
|
browser = { close: async () => { try { await context.close(); } finally { release(); } } } as unknown as Browser;
|
|
420
426
|
// Measured 16 Oct: the channel build runs as this app, not "Chromium" — activating
|
|
421
427
|
// "Chromium" failed and the window stayed behind the others.
|
|
422
|
-
appName = 'Google Chrome for Testing';
|
|
428
|
+
appName = channel === 'chrome' ? 'Google Chrome' : 'Google Chrome for Testing';
|
|
423
429
|
} else {
|
|
424
430
|
({ browser, appName } = await launchHeaded());
|
|
425
431
|
// Load an existing storageState so the browser opens ALREADY authenticated
|
package/src/daemon-routes.ts
CHANGED
|
@@ -110,8 +110,15 @@ export function registerDaemonRoutes(r: Hono, deps: LensDeps): void {
|
|
|
110
110
|
password?: string;
|
|
111
111
|
load_state_path?: string;
|
|
112
112
|
profile?: string;
|
|
113
|
+
channel?: string;
|
|
113
114
|
};
|
|
114
115
|
if (!body.url) return c.json({ ok: false, error: 'url required' }, 400);
|
|
116
|
+
if (body.channel !== undefined && body.channel !== 'chrome' && body.channel !== 'chromium') {
|
|
117
|
+
return c.json({ ok: false, error: 'channel must be "chrome" or "chromium"' }, 400);
|
|
118
|
+
}
|
|
119
|
+
if (body.channel !== undefined && typeof body.profile !== 'string') {
|
|
120
|
+
return c.json({ ok: false, error: 'channel applies to a profile login — pass profile too' }, 400);
|
|
121
|
+
}
|
|
115
122
|
// F009.132 — with a profile the login lives in the profile; the state file is
|
|
116
123
|
// an optional copy and defaults next to the other states.
|
|
117
124
|
const statePath = body.state_path ?? (typeof body.profile === 'string' ? join(homedir(), '.lens', 'state', `profile-${body.profile}.json`) : undefined);
|
|
@@ -124,6 +131,7 @@ export function registerDaemonRoutes(r: Hono, deps: LensDeps): void {
|
|
|
124
131
|
password: body.password,
|
|
125
132
|
loadStatePath: body.load_state_path,
|
|
126
133
|
...(typeof body.profile === 'string' ? { profile: body.profile } : {}),
|
|
134
|
+
...(body.channel ? { channel: body.channel as 'chrome' | 'chromium' } : {}),
|
|
127
135
|
});
|
|
128
136
|
// F240.10 — pass the window verdict through UNCHANGED. A remote caller is the
|
|
129
137
|
// one who has to tell a human "the browser is open"; it must see what the
|
package/src/flow.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
type AssertOutcome,
|
|
24
24
|
type Target,
|
|
25
25
|
} from '@broberg/lens-engine';
|
|
26
|
-
import { withPage, type LensEngine } from './browser.ts';
|
|
26
|
+
import { withPage, type LensEngine, type RunBrowser } from './browser.ts';
|
|
27
27
|
import { collectNetworkShapes, type NetworkShapesOptions, type NetworkShapesReport } from './network-shapes.ts';
|
|
28
28
|
import { deadline, defaultPopupOf, prepareExtension, seedExtensionStorage, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
|
|
29
29
|
import { authPrepare, fetchMintJson, persistStorageState, persistTarget, type AuthConfig, type AuthEnv } from './auth/index.ts';
|
|
@@ -143,6 +143,9 @@ export type FlowStep =
|
|
|
143
143
|
// URI; an array = a multi-file upload. Drives real add-photo/attach steps a DSL
|
|
144
144
|
// could not before (fd-sundhed skadefoto-flow, lens-gap 019f581a).
|
|
145
145
|
| { type: 'uploadFile'; testid: string; path: string | string[]; label?: string; timeout?: number }
|
|
146
|
+
// F009.136 — the CSS twin of uploadFile, for an input with no testid (YouTube
|
|
147
|
+
// Studio's sits in a shadow root; Playwright's CSS engine pierces open ones).
|
|
148
|
+
| { type: 'uploadSelector'; selector: string; path: string | string[]; frame?: string | string[]; label?: string; timeout?: number }
|
|
146
149
|
// F213.3 — composite + control-flow verbs for FMC-level manuscripts.
|
|
147
150
|
// autocomplete: type into a field, wait for the suggestion list to render, then
|
|
148
151
|
// pick a suggestion by visible text (pickText) or index (pickIndex) — a real
|
|
@@ -186,6 +189,10 @@ export interface Flow {
|
|
|
186
189
|
* standing in for the toolbar click (activeTab) a headless run cannot make;
|
|
187
190
|
* the original build is never written, and the report says it was simulated. */
|
|
188
191
|
extension?: FlowExtension;
|
|
192
|
+
/** F009.136 — run in a named browser build with its own profile:
|
|
193
|
+
* {channel:'chrome', profile:'<name>'} = the installed Google Chrome with a kept
|
|
194
|
+
* login. Google refuses a sign-in in Playwright's Chromium. Not with extension. */
|
|
195
|
+
browser?: RunBrowser;
|
|
189
196
|
/** F009.133 — record the SHAPE (never a value) of every call the page makes:
|
|
190
197
|
* fetch/xhr request + response bodies and WebSocket frames. */
|
|
191
198
|
network_shapes?: true | NetworkShapesOptions;
|
|
@@ -482,8 +489,8 @@ export const STEP_TYPES = new Set([
|
|
|
482
489
|
'fillSelector', 'clickSelector', 'waitForUrl', 'inspect',
|
|
483
490
|
// F213.2 — interaction verbs
|
|
484
491
|
'clear', 'press', 'drag',
|
|
485
|
-
// F213.6 — file upload
|
|
486
|
-
'uploadFile',
|
|
492
|
+
// F213.6 — file upload; F009.136 — by CSS selector
|
|
493
|
+
'uploadFile', 'uploadSelector',
|
|
487
494
|
// F213.3 — composite + control-flow verbs
|
|
488
495
|
'autocomplete', 'capture', 'conditional',
|
|
489
496
|
// F255.7 — gate on the deployed build actually serving
|
|
@@ -549,6 +556,10 @@ export function parseFlow(input: unknown): Flow {
|
|
|
549
556
|
...(typeof e.profile === 'string' ? { profile: e.profile } : {}),
|
|
550
557
|
};
|
|
551
558
|
}
|
|
559
|
+
const browser = f.browser === undefined ? undefined : parseRunBrowser(f.browser);
|
|
560
|
+
if (browser && extension) {
|
|
561
|
+
throw new Error('flow.browser and flow.extension cannot be combined — an extension run picks its own build; keep a login with flow.extension.profile instead');
|
|
562
|
+
}
|
|
552
563
|
persistTarget(f.auth as AuthConfig | undefined); // F009.131 — refuse a persist that cannot be honoured
|
|
553
564
|
validateAndNormalizeSteps(f.steps);
|
|
554
565
|
// F009.133 — refused when malformed, for the same reason as extension above.
|
|
@@ -574,11 +585,33 @@ export function parseFlow(input: unknown): Flow {
|
|
|
574
585
|
...(f.wait_for_build !== undefined ? { wait_for_build: f.wait_for_build } : {}),
|
|
575
586
|
...(f.eject_grace_ms !== undefined ? { eject_grace_ms: f.eject_grace_ms } : {}),
|
|
576
587
|
...(extension ? { extension } : {}),
|
|
588
|
+
...(browser ? { browser } : {}),
|
|
577
589
|
...(networkShapes ? { network_shapes: networkShapes } : {}),
|
|
578
590
|
steps: f.steps as FlowStep[],
|
|
579
591
|
};
|
|
580
592
|
}
|
|
581
593
|
|
|
594
|
+
/** F009.136 — refused when malformed, never dropped: a run that silently fell
|
|
595
|
+
* back to Chromium would land on Google's "browser not secure" page and look like
|
|
596
|
+
* a login problem. Exported so lens_run_manuscript validates the same way. */
|
|
597
|
+
export function parseRunBrowser(input: unknown): RunBrowser {
|
|
598
|
+
const shape = 'browser must be {channel: "chrome" | "chromium", profile?: "<name>", visible?: boolean}';
|
|
599
|
+
const b = input as Record<string, unknown>;
|
|
600
|
+
if (!b || typeof b !== 'object' || Array.isArray(b) || Object.keys(b).some((k) => !['channel', 'profile', 'visible'].includes(k))) {
|
|
601
|
+
throw new Error(shape);
|
|
602
|
+
}
|
|
603
|
+
if (b.channel !== 'chrome' && b.channel !== 'chromium') throw new Error(`${shape} — channel is required`);
|
|
604
|
+
if (b.profile !== undefined && (typeof b.profile !== 'string' || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(b.profile))) {
|
|
605
|
+
throw new Error('browser.profile must be 1-40 characters of a-z, 0-9 and dash, starting with a letter or digit');
|
|
606
|
+
}
|
|
607
|
+
if (b.visible !== undefined && typeof b.visible !== 'boolean') throw new Error('browser.visible must be a boolean');
|
|
608
|
+
return {
|
|
609
|
+
channel: b.channel,
|
|
610
|
+
...(typeof b.profile === 'string' ? { profile: b.profile } : {}),
|
|
611
|
+
...(b.visible === true ? { visible: true } : {}),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
582
615
|
function requestRegex(u: string): RegExp {
|
|
583
616
|
try {
|
|
584
617
|
return new RegExp(u);
|
|
@@ -701,6 +734,7 @@ export const STEP_FIELDS: Record<string, string[]> = {
|
|
|
701
734
|
press: ['key', 'testid'],
|
|
702
735
|
drag: ['testid', 'toTestid', 'by', 'steps'],
|
|
703
736
|
uploadFile: ['testid', 'path'],
|
|
737
|
+
uploadSelector: ['selector', 'path', 'frame'],
|
|
704
738
|
autocomplete: ['testid', 'value', 'option', 'pickText', 'pickIndex'],
|
|
705
739
|
capture: ['testid', 'as', 'from'],
|
|
706
740
|
conditional: ['testid', 'present', 'steps'],
|
|
@@ -891,12 +925,12 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = fal
|
|
|
891
925
|
throw new Error('press step requires a string key');
|
|
892
926
|
}
|
|
893
927
|
// F213.6 — uploadFile needs a string path or a non-empty string[] of paths.
|
|
894
|
-
if (s.type === 'uploadFile') {
|
|
928
|
+
if (s.type === 'uploadFile' || s.type === 'uploadSelector') {
|
|
895
929
|
const p = (s as { path?: unknown }).path;
|
|
896
930
|
const ok = typeof p === 'string' ? p.length > 0 : Array.isArray(p) && p.length > 0 && p.every((x) => typeof x === 'string' && x.length > 0);
|
|
897
931
|
if (!ok) throw new Error('uploadFile step requires a non-empty string path or string[] path');
|
|
898
932
|
}
|
|
899
|
-
if ((s.type === 'fillSelector' || s.type === 'clickSelector') && typeof (s as { selector?: unknown }).selector !== 'string') {
|
|
933
|
+
if ((s.type === 'fillSelector' || s.type === 'clickSelector' || s.type === 'uploadSelector') && typeof (s as { selector?: unknown }).selector !== 'string') {
|
|
900
934
|
throw new Error(`${s.type} step requires a string selector`);
|
|
901
935
|
}
|
|
902
936
|
if (s.type === 'fillSelector' && typeof (s as { value?: unknown }).value !== 'string') {
|
|
@@ -1170,7 +1204,7 @@ function splitFrame(s: string): [string, string[] | null] {
|
|
|
1170
1204
|
*/
|
|
1171
1205
|
export const MANUSCRIPT_VERBS = [
|
|
1172
1206
|
'goto', 'click', 'fill', 'select', 'check', 'uncheck', 'clear',
|
|
1173
|
-
'press', 'drag', 'upload', 'autocomplete',
|
|
1207
|
+
'press', 'drag', 'upload', 'uploadSelector', 'autocomplete',
|
|
1174
1208
|
'waitFor', 'waitForUrl', 'waitForBuild',
|
|
1175
1209
|
'expectText', 'expectVisible', 'expectEditable', 'expectAbsent', 'expectTopmost',
|
|
1176
1210
|
'fillSelector', 'clickSelector',
|
|
@@ -1321,6 +1355,18 @@ function parseManuscriptStep(s: string): FlowStep {
|
|
|
1321
1355
|
...(frame ? { frame } : {}),
|
|
1322
1356
|
};
|
|
1323
1357
|
}
|
|
1358
|
+
case 'uploadselector': {
|
|
1359
|
+
// F009.136 — "uploadSelector <css> = <fixture>[, <fixture>…]" (optionally
|
|
1360
|
+
// "@frame <css>" after the selector). Same fixtures and sandbox as upload.
|
|
1361
|
+
const eq = s.indexOf('=');
|
|
1362
|
+
if (eq < 0) throw new Error(`uploadSelector step "${s}" needs "<css selector> = <fixture path>"`);
|
|
1363
|
+
const [main, frame] = splitFrame(s.slice(0, eq));
|
|
1364
|
+
const selector = main.replace(/^uploadSelector\s+/i, '').trim();
|
|
1365
|
+
const rhs = s.slice(eq + 1).trim();
|
|
1366
|
+
if (!selector || !rhs) throw new Error(`uploadSelector step "${s}" needs "<css selector> = <fixture path>"`);
|
|
1367
|
+
const parts = rhs.startsWith('data:') ? [rhs] : rhs.split(',').map((x) => x.trim()).filter(Boolean);
|
|
1368
|
+
return { type: 'uploadSelector', selector, path: parts.length === 1 ? parts[0]! : parts, ...(frame ? { frame } : {}) };
|
|
1369
|
+
}
|
|
1324
1370
|
case 'clickselector': {
|
|
1325
1371
|
const [main, frame] = splitFrame(s);
|
|
1326
1372
|
return {
|
|
@@ -1387,7 +1433,7 @@ function subst(str: string, vars: Record<string, unknown>): string {
|
|
|
1387
1433
|
* only its value. cardmem's flow 62ca6136 sent `[data-go="{{s}}"]` to Playwright
|
|
1388
1434
|
* literally on all 9 iterations. Other step kinds are returned unchanged. */
|
|
1389
1435
|
function bindSelectorVars(step: FlowStep, vars: Record<string, unknown>): FlowStep {
|
|
1390
|
-
if (step.type !== 'fillSelector' && step.type !== 'clickSelector') return step;
|
|
1436
|
+
if (step.type !== 'fillSelector' && step.type !== 'clickSelector' && step.type !== 'uploadSelector') return step;
|
|
1391
1437
|
const frame = step.frame == null ? step.frame : Array.isArray(step.frame) ? step.frame.map((f) => subst(f, vars)) : subst(step.frame, vars);
|
|
1392
1438
|
return {
|
|
1393
1439
|
...step,
|
|
@@ -1539,6 +1585,7 @@ function describe(step: FlowStep): string {
|
|
|
1539
1585
|
}
|
|
1540
1586
|
case 'fillSelector': return `fillSelector ${step.selector}`;
|
|
1541
1587
|
case 'clickSelector': return `clickSelector ${step.selector}${step.at ? ` @ ${step.at}` : ''}`;
|
|
1588
|
+
case 'uploadSelector': return `upload ${Array.isArray(step.path) ? `${step.path.length} files` : (step.path.startsWith('data:') ? 'data:…' : step.path)} → ${step.selector}`;
|
|
1542
1589
|
case 'waitForUrl': return `waitForUrl ${step.url}`;
|
|
1543
1590
|
case 'inspect': return step.html ? `inspect html${step.testid ? ` [${step.testid}]` : ''}` : step.frame ? 'inspect @frame' : 'inspect';
|
|
1544
1591
|
case 'drag': return `drag ${step.testid} ${step.by ? `by ${step.by.x},${step.by.y}` : `→ ${step.toTestid}`}`;
|
|
@@ -1847,7 +1894,7 @@ export function ejectionOf(loaded: string, current: string): string | null {
|
|
|
1847
1894
|
* trust in the instrument.
|
|
1848
1895
|
*/
|
|
1849
1896
|
const NAVIGATES_BY_DESIGN = new Set([
|
|
1850
|
-
'goto', 'click', 'clickSelector', 'press', 'drag', 'autocomplete', 'uploadFile',
|
|
1897
|
+
'goto', 'click', 'clickSelector', 'press', 'drag', 'autocomplete', 'uploadFile', 'uploadSelector',
|
|
1851
1898
|
'waitForUrl', 'waitForBuild',
|
|
1852
1899
|
// Every one of these can route on a change handler: a search box that submits,
|
|
1853
1900
|
// a <select> that filters by URL, a checkbox that writes a query param.
|
|
@@ -2438,6 +2485,17 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
|
|
|
2438
2485
|
await page.locator(sel(step.testid)).first().setInputFiles(files, { timeout: stepTimeout(step) });
|
|
2439
2486
|
return null;
|
|
2440
2487
|
}
|
|
2488
|
+
case 'uploadSelector': {
|
|
2489
|
+
const b = bindSelectorVars(step, vars) as typeof step;
|
|
2490
|
+
const paths = Array.isArray(b.path) ? b.path : [b.path];
|
|
2491
|
+
const isData = (p: string) => p.startsWith('data:');
|
|
2492
|
+
if (paths.some(isData) && !paths.every(isData)) {
|
|
2493
|
+
throw new Error('uploadSelector: a step must be all data: URIs OR all fixture paths, not a mix');
|
|
2494
|
+
}
|
|
2495
|
+
const files = paths.every(isData) ? paths.map(dataUriToPayload) : paths.map((p) => resolveFixturePath(localPath, p));
|
|
2496
|
+
await resolveLocator(page, b.selector, b.frame).first().setInputFiles(files, { timeout: stepTimeout(step) });
|
|
2497
|
+
return null;
|
|
2498
|
+
}
|
|
2441
2499
|
case 'waitForUrl': {
|
|
2442
2500
|
const want = subst(step.url, vars);
|
|
2443
2501
|
await page.waitForURL((u) => u.toString().includes(want), { timeout: stepTimeout(step) });
|
|
@@ -3197,6 +3255,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
3197
3255
|
reducedMotion: opts.reducedMotion,
|
|
3198
3256
|
...(videoDir ? { recordVideoDir: videoDir } : {}),
|
|
3199
3257
|
...(prepared ? { extension: { path: prepared.dir, ...(flow.extension?.profile ? { profile: flow.extension.profile } : {}) } } : {}),
|
|
3258
|
+
...(flow.browser ? { browser: flow.browser } : {}),
|
|
3200
3259
|
},
|
|
3201
3260
|
async (page, context, ext) => {
|
|
3202
3261
|
videoPage = page;
|
package/src/mcp.ts
CHANGED
|
@@ -29,6 +29,18 @@ import {
|
|
|
29
29
|
lensExport,
|
|
30
30
|
} from './service.ts';
|
|
31
31
|
|
|
32
|
+
const browserSchema = {
|
|
33
|
+
type: 'object',
|
|
34
|
+
required: ['channel'],
|
|
35
|
+
description:
|
|
36
|
+
'F009.136 — run in this browser BUILD with its own user-data dir. {channel:"chrome", profile:"<name>"} = the installed Google Chrome with the kept profile ~/.lens/profiles/<name>, de-flagged (no automation bar, no navigator.webdriver, no HeadlessChrome in the user agent). Use it for Google sign-ins (YouTube Studio, Postmaster Tools, Chrome Web Store), which refuse Playwright\'s Chromium ("this browser may not be secure"). Log in ONCE via POST /lens/capture-session {url, profile, channel:"chrome"} (a visible window; a human types password + 2FA), then reuse the profile in any run and from any project. A profile remembers its build: using it with the other channel is refused. One run per profile at a time. visible:true opens a window instead of headless. Not with extension.',
|
|
37
|
+
properties: {
|
|
38
|
+
channel: { type: 'string', enum: ['chrome', 'chromium'] },
|
|
39
|
+
profile: { type: 'string', description: '1-40 chars a-z 0-9 -. Omit for a fresh temp profile.' },
|
|
40
|
+
visible: { type: 'boolean' },
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
32
44
|
const authSchema = {
|
|
33
45
|
type: 'object',
|
|
34
46
|
description:
|
|
@@ -362,6 +374,7 @@ export const TOOLS = [
|
|
|
362
374
|
'F255.7 — do not start until the app is serving the build under test. A commit sha waits for THAT build; true waits for any change from the build live at run start. Polls GET /api/health for a `build` field (the fleet contract), from the runner with no-store, requiring 3 consecutive matching reads so a fleet mid-rollout cannot end the wait early. Fails loudly if the app exposes no marker. Use it right after a deploy — otherwise a green run may be verifying the build you just replaced.',
|
|
363
375
|
},
|
|
364
376
|
auth: authSchema,
|
|
377
|
+
browser: browserSchema,
|
|
365
378
|
network_shapes: {
|
|
366
379
|
type: ['boolean', 'object'],
|
|
367
380
|
description:
|
|
@@ -392,7 +405,7 @@ export const TOOLS = [
|
|
|
392
405
|
{
|
|
393
406
|
name: 'lens_run_manuscript',
|
|
394
407
|
description:
|
|
395
|
-
`Compile a human-readable markdown manuscript (fmc-automation style: "# flow: name", "base: <url>", then one "- <verb> …" per line) into a flow and run it E2E. VERBS (${MANUSCRIPT_VERBS.length}, this is the complete list): ${MANUSCRIPT_VERBS.join(' / ')}. Shapes: "- goto /x" · "- fill [testid] = value" · a PASSWORD without plaintext: "- fill [testid] from file <path>" (absolute, or relative to local_path) or "- fill [testid] from env <NAME>" (F009.121 — password inputs only; the value never appears in the run, the report or a screenshot) · "- click [testid]" · "- press Enter" (global) or "- press Enter [testid]" (on a focused element) · "- expectText [testid] ~ text" · "- drag [source] -> [target]" or "- drag [node] by 120,-40" (a canvas offset; both move in 10 mousemoves so React Flow / d3-drag nodes really move — "steps N" to change, F009.123) · "- upload [input-testid] = <fixture>" · "- assert: <js>" · "- screenshot label" · frame-aware "- fillSelector <css> = <value> @frame <iframe>" and "- clickSelector <css> @frame <iframe>" · "- waitForUrl <substring>". TWO BROWSERS AT ONCE (F009.122): prefix a step with "in <name>:" — "- in b: goto /" · "- in b: expectText [note] == Hej" — and it runs in its own browser (own cookies + storage), open alongside the default one for the rest of the run; steps without the prefix run in the default browser. Click modifiers: "- click [testid] via enter" (F255.1, for Angular/MDC buttons a mouse click does not activate) and "- click [testid] @ 50%,28%" (F255.4, aim at a point inside a canvas/3D surface). A field that already HOLDS a value may no longer render an input — a combobox typically swaps to its selected state, so "fill" times out there while "press ArrowDown" / "press Enter" still drive it (fd-sundhed, 2026-08-21). The authoring shortcut — write prose, not JSON.`,
|
|
408
|
+
`Compile a human-readable markdown manuscript (fmc-automation style: "# flow: name", "base: <url>", then one "- <verb> …" per line) into a flow and run it E2E. VERBS (${MANUSCRIPT_VERBS.length}, this is the complete list): ${MANUSCRIPT_VERBS.join(' / ')}. Shapes: "- goto /x" · "- fill [testid] = value" · a PASSWORD without plaintext: "- fill [testid] from file <path>" (absolute, or relative to local_path) or "- fill [testid] from env <NAME>" (F009.121 — password inputs only; the value never appears in the run, the report or a screenshot) · "- click [testid]" · "- press Enter" (global) or "- press Enter [testid]" (on a focused element) · "- expectText [testid] ~ text" · "- drag [source] -> [target]" or "- drag [node] by 120,-40" (a canvas offset; both move in 10 mousemoves so React Flow / d3-drag nodes really move — "steps N" to change, F009.123) · "- upload [input-testid] = <fixture>" · an input with NO testid, also inside an open shadow root: "- uploadSelector <css> = <fixture>[, <fixture>]" (F009.136; fixtures repo-relative to local_path or data:) · "- assert: <js>" · "- screenshot label" · frame-aware "- fillSelector <css> = <value> @frame <iframe>" and "- clickSelector <css> @frame <iframe>" · "- waitForUrl <substring>". TWO BROWSERS AT ONCE (F009.122): prefix a step with "in <name>:" — "- in b: goto /" · "- in b: expectText [note] == Hej" — and it runs in its own browser (own cookies + storage), open alongside the default one for the rest of the run; steps without the prefix run in the default browser. Click modifiers: "- click [testid] via enter" (F255.1, for Angular/MDC buttons a mouse click does not activate) and "- click [testid] @ 50%,28%" (F255.4, aim at a point inside a canvas/3D surface). A field that already HOLDS a value may no longer render an input — a combobox typically swaps to its selected state, so "fill" times out there while "press ArrowDown" / "press Enter" still drive it (fd-sundhed, 2026-08-21). The authoring shortcut — write prose, not JSON.`,
|
|
396
409
|
inputSchema: {
|
|
397
410
|
type: 'object',
|
|
398
411
|
required: ['project', 'manuscript'],
|
|
@@ -408,6 +421,7 @@ export const TOOLS = [
|
|
|
408
421
|
timezone: timezoneSchema,
|
|
409
422
|
reduced_motion: reducedMotionSchema,
|
|
410
423
|
storage: storageSchema,
|
|
424
|
+
browser: browserSchema,
|
|
411
425
|
record: {
|
|
412
426
|
type: 'boolean',
|
|
413
427
|
description: 'F255.5 — record the whole driven run as a .webm and return it as video_url. Off by default.',
|
package/src/profiles.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// F009.132 — a fixed browser profile per site. Google binds its session to the
|
|
2
2
|
// browser that holds it, so a login only survives if it happens in, and is reused
|
|
3
3
|
// from, ONE profile of ONE browser build. The profile lives on the daemon host.
|
|
4
|
-
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
|
|
@@ -29,3 +29,26 @@ export function claimProfile(name: string): () => void {
|
|
|
29
29
|
inUse.add(name);
|
|
30
30
|
return () => inUse.delete(name);
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
// F009.136 — a profile is bound to the browser BUILD it was first used with. Google
|
|
34
|
+
// keeps the session in the profile but checks it against the browser holding it, so
|
|
35
|
+
// a Chromium profile opened in Chrome (or the reverse) is a mystery logout on the
|
|
36
|
+
// next run. The build is written into the profile on first use and a mismatch is
|
|
37
|
+
// refused before a browser starts, naming both.
|
|
38
|
+
export type ProfileChannel = 'chrome' | 'chromium';
|
|
39
|
+
const MARKER = '.lens-channel';
|
|
40
|
+
|
|
41
|
+
export function bindProfileChannel(name: string, channel: ProfileChannel): void {
|
|
42
|
+
const marker = join(profileDir(name), MARKER);
|
|
43
|
+
if (!existsSync(marker)) {
|
|
44
|
+
writeFileSync(marker, channel, { mode: 0o600 });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const bound = readFileSync(marker, 'utf8').trim();
|
|
48
|
+
if (bound !== channel) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`browser profile "${name}" belongs to ${bound} — this run asked for ${channel}. A login lives in the build it was made in; ` +
|
|
51
|
+
`run with channel "${bound}", or log in again under a new profile name for ${channel}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
listBaselineVersions,
|
|
34
34
|
revertBaseline,
|
|
35
35
|
} from './store.ts';
|
|
36
|
-
import { runFlow, parseFlow, compileManuscript, type FlowResult, type FlowStepResult } from './flow.ts';
|
|
36
|
+
import { runFlow, parseFlow, parseRunBrowser, compileManuscript, type FlowResult, type FlowStepResult } from './flow.ts';
|
|
37
37
|
// F074.24 — the daemon HOSTS the engine's own runFlow for flat cloud-shape
|
|
38
38
|
// FlowRequests (one wire-shape, driven via @broberg/lens-client). The engine
|
|
39
39
|
// natively speaks the cloud grammar (all 12 action-verbs), self-heals via the
|
|
@@ -999,6 +999,8 @@ export interface RunManuscriptInput {
|
|
|
999
999
|
auth?: AuthConfig;
|
|
1000
1000
|
/** F082.6 — repo root for relative auth + manifest-auth inheritance. */
|
|
1001
1001
|
local_path?: string;
|
|
1002
|
+
/** F009.136 — {channel:'chrome', profile?, visible?}: run in that browser build. */
|
|
1003
|
+
browser?: unknown;
|
|
1002
1004
|
/** F252.2 — run the manuscript at a non-default viewport/device so mobile-only
|
|
1003
1005
|
* UI (behind lg:hidden etc.) can be click-driven, not just DOM-asserted. */
|
|
1004
1006
|
viewport?: { width: number; height: number } | string;
|
|
@@ -1030,6 +1032,11 @@ export async function lensRunManuscript(deps: LensDeps, input: RunManuscriptInpu
|
|
|
1030
1032
|
throw new Error('manuscript (markdown string) is required');
|
|
1031
1033
|
}
|
|
1032
1034
|
const flow = compileManuscript(input.manuscript);
|
|
1035
|
+
// F009.136 — the manuscript language has no browser line; the call carries it.
|
|
1036
|
+
if (input.browser !== undefined) {
|
|
1037
|
+
if (flow.extension) throw new Error('browser and extension cannot be combined — keep a login with extension.profile instead');
|
|
1038
|
+
flow.browser = parseRunBrowser(input.browser);
|
|
1039
|
+
}
|
|
1033
1040
|
// F082.6 — a manuscript inherits the project's manifest auth (DRY: the
|
|
1034
1041
|
// credential lives in one place) unless it declared its own.
|
|
1035
1042
|
// F074.36 — an explicit `auth` argument outranks both, and the provenance is
|