@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broberg/lens-daemon",
3
- "version": "0.1.31",
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.33.5",
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(
@@ -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
- channel: EXTENSION_CHANNEL,
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
@@ -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