@broberg/lens-daemon 0.1.13 → 0.1.15
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 +3 -3
- package/src/critic-vision.ts +2 -1
- package/src/extension-flow.ts +21 -0
- package/src/flow.ts +111 -5
- package/src/mcp.ts +3 -2
- package/src/validate-vision.ts +2 -1
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/lens-daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
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",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@broberg/ai-sdk": "0.
|
|
8
|
+
"@broberg/ai-sdk": "0.63.0",
|
|
9
9
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
10
10
|
"@mozilla/readability": "^0.6.0",
|
|
11
11
|
"hono": "^4.6.14",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"turndown": "^7.2.4",
|
|
17
17
|
"zod": "^3.23.8",
|
|
18
18
|
"@broberg/lens-svg": "0.1.0",
|
|
19
|
-
"@broberg/lens-engine": "0.13.
|
|
19
|
+
"@broberg/lens-engine": "0.13.1"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@happy-dom/global-registrator": "^20.10.6",
|
package/src/critic-vision.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// when projects.settings_json.composition_critic.vision === true (F126.3). This
|
|
11
11
|
// module makes a call ONLY when asked; it never runs itself.
|
|
12
12
|
|
|
13
|
+
import { AI_AGENT_NAME } from '@broberg/lens-engine';
|
|
13
14
|
import type { CriticSeverity } from './critic-dom.ts';
|
|
14
15
|
|
|
15
16
|
export interface VisionFinding {
|
|
@@ -126,7 +127,7 @@ function getDaemonAi() {
|
|
|
126
127
|
if (process.env.OPENROUTER_API_KEY) providers.openrouter = sdk.openrouterAdapter({ apiKey: process.env.OPENROUTER_API_KEY });
|
|
127
128
|
if (Object.keys(providers).length === 0) return null;
|
|
128
129
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
129
|
-
return sdk.createAI({ providers: providers as any }) as any;
|
|
130
|
+
return sdk.createAI({ providers: providers as any, agentName: AI_AGENT_NAME }) as any;
|
|
130
131
|
} catch {
|
|
131
132
|
return null;
|
|
132
133
|
}
|
package/src/extension-flow.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
18
|
+
import type { BrowserContext } from 'playwright';
|
|
18
19
|
import { join } from 'node:path';
|
|
19
20
|
|
|
20
21
|
interface Manifest {
|
|
@@ -80,3 +81,23 @@ export async function prepareExtension(path: string, grantHostAccess: boolean):
|
|
|
80
81
|
export const SIMULATED_ACCESS_NOTE =
|
|
81
82
|
'host access SIMULATED: the extension ran from a temporary copy that may read every page, ' +
|
|
82
83
|
'standing in for the toolbar click (activeTab) a headless run cannot make';
|
|
84
|
+
|
|
85
|
+
/** F009.126 — write `values` into the loaded extension's chrome.storage.local, from
|
|
86
|
+
* the extension's own context (MV3 service worker, or MV2 background page), so the
|
|
87
|
+
* popup's first render already finds them — a login it keeps there, for example.
|
|
88
|
+
* Read back and compared, so a seed that did not land fails the run here instead
|
|
89
|
+
* of showing up later as a popup that is mysteriously logged out. */
|
|
90
|
+
export async function seedExtensionStorage(context: BrowserContext, values: Record<string, unknown>): Promise<void> {
|
|
91
|
+
const target = context.serviceWorkers()[0] ?? context.backgroundPages()[0];
|
|
92
|
+
if (!target) throw new Error('storage_local: the extension has no service worker or background page to write chrome.storage.local from');
|
|
93
|
+
const keys = Object.keys(values);
|
|
94
|
+
const back = await target.evaluate(async (v: Record<string, unknown>) => {
|
|
95
|
+
const c = (globalThis as unknown as { chrome: { storage?: { local: { set(x: object): Promise<void>; get(k: string[]): Promise<Record<string, unknown>> } } } }).chrome;
|
|
96
|
+
if (!c.storage) return null;
|
|
97
|
+
await c.storage.local.set(v);
|
|
98
|
+
return c.storage.local.get(Object.keys(v));
|
|
99
|
+
}, values);
|
|
100
|
+
if (!back) throw new Error('storage_local: the extension has no chrome.storage — its manifest does not request the "storage" permission, so there is nowhere to seed');
|
|
101
|
+
const missing = keys.filter((k) => JSON.stringify(back[k]) !== JSON.stringify(values[k]));
|
|
102
|
+
if (missing.length) throw new Error(`storage_local: chrome.storage.local did not keep ${missing.join(', ')} — read back differs from what was written`);
|
|
103
|
+
}
|
package/src/flow.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
type Target,
|
|
25
25
|
} from '@broberg/lens-engine';
|
|
26
26
|
import { withPage, type LensEngine } from './browser.ts';
|
|
27
|
-
import { defaultPopupOf, prepareExtension, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
|
|
27
|
+
import { defaultPopupOf, prepareExtension, seedExtensionStorage, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
|
|
28
28
|
import { authPrepare, type AuthConfig, type AuthEnv } from './auth/index.ts';
|
|
29
29
|
import { takeShot, settleForShot } from './capture.ts';
|
|
30
30
|
import { runCritic, topFindingMessage, type CriticMode, type CriticReport } from './critic.ts';
|
|
@@ -158,7 +158,11 @@ export type FlowStep =
|
|
|
158
158
|
// F009.125 — open the loaded extension's popup as its own page in the SAME browser,
|
|
159
159
|
// then put the page under test back in front so the extension sees it as the active
|
|
160
160
|
// tab (measured). `path` defaults to the manifest's action.default_popup.
|
|
161
|
-
| { type: 'openPopup'; path?: string; label?: string; timeout?: number }
|
|
161
|
+
| { type: 'openPopup'; path?: string; label?: string; timeout?: number }
|
|
162
|
+
// F009.126 — pass when the run made a matching request (page, popup, any page in the
|
|
163
|
+
// run's browser). `url` is a substring, or a regex with match:"regex" (a path like
|
|
164
|
+
// "/api/clips" would otherwise read as a /pattern/flags literal). Fails listing what WAS seen.
|
|
165
|
+
| { type: 'expectRequest'; url: string; match?: 'contains' | 'regex'; method?: string; status?: number; label?: string; timeout?: number };
|
|
162
166
|
|
|
163
167
|
export interface Flow {
|
|
164
168
|
name: string;
|
|
@@ -187,6 +191,9 @@ export interface Flow {
|
|
|
187
191
|
export interface FlowExtension {
|
|
188
192
|
path: string;
|
|
189
193
|
grant_host_access?: boolean;
|
|
194
|
+
/** F009.126 — written to the extension's chrome.storage.local before step 0, so a
|
|
195
|
+
* popup that keeps its login there starts signed in (OAuth cannot run headless). */
|
|
196
|
+
storage_local?: Record<string, unknown>;
|
|
190
197
|
}
|
|
191
198
|
|
|
192
199
|
/**
|
|
@@ -425,6 +432,8 @@ export const STEP_TYPES = new Set([
|
|
|
425
432
|
'waitForReady',
|
|
426
433
|
// F009.125 — drive a loaded extension's popup
|
|
427
434
|
'openPopup',
|
|
435
|
+
// F009.126 — assert a request the run made
|
|
436
|
+
'expectRequest',
|
|
428
437
|
]);
|
|
429
438
|
|
|
430
439
|
/** Validate + normalise an untrusted flow (HTTP/MCP boundary). Throws on the
|
|
@@ -458,13 +467,21 @@ export function parseFlow(input: unknown): Flow {
|
|
|
458
467
|
if (f.extension !== undefined) {
|
|
459
468
|
const e = f.extension as unknown as Record<string, unknown>;
|
|
460
469
|
const keys = e && typeof e === 'object' && !Array.isArray(e) ? Object.keys(e) : null;
|
|
461
|
-
if (!keys || typeof e.path !== 'string' || !e.path || keys.some((k) => k !== 'path' && k !== 'grant_host_access')) {
|
|
462
|
-
throw new Error('flow.extension must be {path: "<unpacked build dir>", grant_host_access?: boolean}');
|
|
470
|
+
if (!keys || typeof e.path !== 'string' || !e.path || keys.some((k) => k !== 'path' && k !== 'grant_host_access' && k !== 'storage_local')) {
|
|
471
|
+
throw new Error('flow.extension must be {path: "<unpacked build dir>", grant_host_access?: boolean, storage_local?: {…}}');
|
|
463
472
|
}
|
|
464
473
|
if (e.grant_host_access !== undefined && typeof e.grant_host_access !== 'boolean') {
|
|
465
474
|
throw new Error('flow.extension.grant_host_access must be a boolean');
|
|
466
475
|
}
|
|
467
|
-
|
|
476
|
+
const sl = e.storage_local;
|
|
477
|
+
if (sl !== undefined && (sl === null || typeof sl !== 'object' || Array.isArray(sl))) {
|
|
478
|
+
throw new Error('flow.extension.storage_local must be an object of {key: JSON value} — it is passed to chrome.storage.local.set as-is');
|
|
479
|
+
}
|
|
480
|
+
extension = {
|
|
481
|
+
path: e.path,
|
|
482
|
+
...(e.grant_host_access === true ? { grant_host_access: true } : {}),
|
|
483
|
+
...(sl !== undefined ? { storage_local: sl as Record<string, unknown> } : {}),
|
|
484
|
+
};
|
|
468
485
|
}
|
|
469
486
|
validateAndNormalizeSteps(f.steps);
|
|
470
487
|
if (!extension && usesPopup(f.steps as FlowStep[])) {
|
|
@@ -482,6 +499,38 @@ export function parseFlow(input: unknown): Flow {
|
|
|
482
499
|
};
|
|
483
500
|
}
|
|
484
501
|
|
|
502
|
+
function requestRegex(u: string): RegExp {
|
|
503
|
+
try {
|
|
504
|
+
return new RegExp(u);
|
|
505
|
+
} catch (err) {
|
|
506
|
+
throw new Error(`expectRequest: url ${u} is not a valid regex — ${err instanceof Error ? err.message : String(err)}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** F009.126 — one request the run's browser made, as expectRequest sees it. */
|
|
511
|
+
export interface SeenRequest {
|
|
512
|
+
method: string;
|
|
513
|
+
url: string;
|
|
514
|
+
/** null while in flight, or when it failed before a response. */
|
|
515
|
+
status: number | null;
|
|
516
|
+
failed?: string;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** F009.126 — does this request satisfy the step? Exported for test. */
|
|
520
|
+
export function requestMatches(r: SeenRequest, want: { url: string; match?: 'contains' | 'regex'; method?: string; status?: number }): boolean {
|
|
521
|
+
const urlOk = want.match === 'regex' ? requestRegex(want.url).test(r.url) : r.url.includes(want.url);
|
|
522
|
+
if (!urlOk) return false;
|
|
523
|
+
if (want.method && r.method.toUpperCase() !== want.method.toUpperCase()) return false;
|
|
524
|
+
if (want.status !== undefined && r.status !== want.status) return false;
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Query strings can carry tokens; a failure report shows the path only. */
|
|
529
|
+
function redactQuery(u: string): string {
|
|
530
|
+
const q = u.indexOf('?');
|
|
531
|
+
return q === -1 ? u : `${u.slice(0, q)}?…`;
|
|
532
|
+
}
|
|
533
|
+
|
|
485
534
|
function usesPopup(steps: FlowStep[]): boolean {
|
|
486
535
|
return steps.some(
|
|
487
536
|
(st) =>
|
|
@@ -551,6 +600,7 @@ export const STEP_FIELDS: Record<string, string[]> = {
|
|
|
551
600
|
conditional: ['testid', 'present', 'steps'],
|
|
552
601
|
loop: ['data', 'steps', 'parallel'],
|
|
553
602
|
openPopup: ['path'],
|
|
603
|
+
expectRequest: ['url', 'match', 'method', 'status'],
|
|
554
604
|
};
|
|
555
605
|
|
|
556
606
|
function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = false): void {
|
|
@@ -633,6 +683,20 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = fal
|
|
|
633
683
|
}
|
|
634
684
|
if ('browser' in s || inParallel) throw new Error(`step ${index} (openPopup): the popup opens in the main browser only`);
|
|
635
685
|
}
|
|
686
|
+
if (s.type === 'expectRequest') {
|
|
687
|
+
const r = s as { url?: unknown; match?: unknown; method?: unknown; status?: unknown };
|
|
688
|
+
if (typeof r.url !== 'string' || !r.url) throw new Error(`step ${index} (expectRequest): url is required — a substring, or a regex with match:"regex"`);
|
|
689
|
+
if (r.match !== undefined && r.match !== 'contains' && r.match !== 'regex') {
|
|
690
|
+
throw new Error(`step ${index} (expectRequest): match must be "contains" (default) or "regex"`);
|
|
691
|
+
}
|
|
692
|
+
if (r.match === 'regex') requestRegex(r.url); // throws on a bad pattern now, not mid-run
|
|
693
|
+
if (r.method !== undefined && (typeof r.method !== 'string' || !/^[A-Za-z]+$/.test(r.method))) {
|
|
694
|
+
throw new Error(`step ${index} (expectRequest): method must be an HTTP method like "POST"`);
|
|
695
|
+
}
|
|
696
|
+
if (r.status !== undefined && (typeof r.status !== 'number' || !Number.isInteger(r.status) || r.status < 100 || r.status > 599)) {
|
|
697
|
+
throw new Error(`step ${index} (expectRequest): status must be an HTTP status code (100–599)`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
636
700
|
if (s.type === 'autocomplete') {
|
|
637
701
|
if (typeof (s as { value?: unknown }).value !== 'string') throw new Error('autocomplete step requires a string value');
|
|
638
702
|
if (typeof (s as { option?: unknown }).option !== 'string') throw new Error('autocomplete step requires a string option testid');
|
|
@@ -1365,6 +1429,7 @@ function describe(step: FlowStep): string {
|
|
|
1365
1429
|
case 'conditional': return `${step.present ? 'if' : 'unless'} [${step.testid}]`;
|
|
1366
1430
|
case 'loop': return 'loop';
|
|
1367
1431
|
case 'openPopup': return `openPopup ${step.path ?? '(manifest popup)'}`;
|
|
1432
|
+
case 'expectRequest': return `expectRequest ${step.method ? `${step.method.toUpperCase()} ` : ''}${step.url}${step.status ? ` → ${step.status}` : ''}`;
|
|
1368
1433
|
case 'waitForBuild': return `waitForBuild${step.sha ? ` ${step.sha.slice(0, 8)}` : ' (changed)'}`;
|
|
1369
1434
|
// F255.4 — show the offset in the step label so a positioned click is
|
|
1370
1435
|
// recognisable in the report (a plain "click canvas" hides where it aimed).
|
|
@@ -2329,6 +2394,8 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
|
|
|
2329
2394
|
throw new Error('loop steps are flattened before execution');
|
|
2330
2395
|
case 'openPopup':
|
|
2331
2396
|
throw new Error('openPopup steps are handled by runSeq, never execStep');
|
|
2397
|
+
case 'expectRequest':
|
|
2398
|
+
throw new Error('expectRequest steps are handled by runSeq, never execStep');
|
|
2332
2399
|
}
|
|
2333
2400
|
}
|
|
2334
2401
|
|
|
@@ -2693,6 +2760,8 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2693
2760
|
const out =
|
|
2694
2761
|
step.type === 'openPopup'
|
|
2695
2762
|
? await openPopup(step)
|
|
2763
|
+
: step.type === 'expectRequest'
|
|
2764
|
+
? await expectRequest(step, vars)
|
|
2696
2765
|
: await execStep(sp, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot);
|
|
2697
2766
|
const obj = out !== null && !Buffer.isBuffer(out) && typeof out === 'object' ? out : null;
|
|
2698
2767
|
const inspect = obj && '__inspect' in obj ? (obj as { __inspect: string }).__inspect : null;
|
|
@@ -2859,6 +2928,41 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2859
2928
|
extCtx.popup = popup;
|
|
2860
2929
|
return null;
|
|
2861
2930
|
};
|
|
2931
|
+
// F009.126 — every request the run's main browser makes, from the first step on.
|
|
2932
|
+
const seenRequests: SeenRequest[] = [];
|
|
2933
|
+
const watchRequests = (context: BrowserContext): void => {
|
|
2934
|
+
const byReq = new Map<object, SeenRequest>();
|
|
2935
|
+
context.on('request', (req) => {
|
|
2936
|
+
const r: SeenRequest = { method: req.method(), url: req.url(), status: null };
|
|
2937
|
+
byReq.set(req, r);
|
|
2938
|
+
seenRequests.push(r);
|
|
2939
|
+
});
|
|
2940
|
+
context.on('requestfinished', async (req) => {
|
|
2941
|
+
const r = byReq.get(req);
|
|
2942
|
+
if (r) r.status = (await req.response().catch(() => null))?.status() ?? null;
|
|
2943
|
+
});
|
|
2944
|
+
context.on('requestfailed', (req) => {
|
|
2945
|
+
const r = byReq.get(req);
|
|
2946
|
+
if (r) r.failed = req.failure()?.errorText ?? 'failed';
|
|
2947
|
+
});
|
|
2948
|
+
};
|
|
2949
|
+
const expectRequest = async (step: Extract<FlowStep, { type: 'expectRequest' }>, vars: Record<string, unknown>): Promise<null> => {
|
|
2950
|
+
const want = { url: subst(step.url, vars), match: step.match, method: step.method, status: step.status };
|
|
2951
|
+
const deadline = Date.now() + stepTimeout(step);
|
|
2952
|
+
for (;;) {
|
|
2953
|
+
if (seenRequests.some((r) => requestMatches(r, want))) return null;
|
|
2954
|
+
if (Date.now() >= deadline) break;
|
|
2955
|
+
await new Promise((res) => setTimeout(res, 100));
|
|
2956
|
+
}
|
|
2957
|
+
const seen = seenRequests
|
|
2958
|
+
.filter((r) => !/^(data|chrome-extension):/.test(r.url))
|
|
2959
|
+
.slice(-10)
|
|
2960
|
+
.map((r) => `${r.method} ${redactQuery(r.url)} → ${r.failed ? `failed (${r.failed})` : r.status ?? 'no response'}`);
|
|
2961
|
+
throw new Error(
|
|
2962
|
+
`no request matched ${want.method ? `${want.method.toUpperCase()} ` : ''}${want.url}${want.status ? ` → ${want.status}` : ''}. ` +
|
|
2963
|
+
(seen.length ? `Seen (last ${seen.length}): ${seen.join('; ')}.` : 'The run made no http(s) requests at all.'),
|
|
2964
|
+
);
|
|
2965
|
+
};
|
|
2862
2966
|
const readVideo = async (): Promise<Buffer | null> => {
|
|
2863
2967
|
if (!videoDir) return null;
|
|
2864
2968
|
try {
|
|
@@ -2912,8 +3016,10 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
|
|
|
2912
3016
|
async (page, context, ext) => {
|
|
2913
3017
|
videoPage = page;
|
|
2914
3018
|
collectFailures(page);
|
|
3019
|
+
watchRequests(context);
|
|
2915
3020
|
if (ext && flow.extension && prepared) {
|
|
2916
3021
|
extCtx = { context, id: ext.id, main: page, popup: null };
|
|
3022
|
+
if (flow.extension.storage_local) await seedExtensionStorage(context, flow.extension.storage_local);
|
|
2917
3023
|
extensionInfo = {
|
|
2918
3024
|
id: ext.id,
|
|
2919
3025
|
path: flow.extension.path,
|
package/src/mcp.ts
CHANGED
|
@@ -364,16 +364,17 @@ export const TOOLS = [
|
|
|
364
364
|
type: 'object',
|
|
365
365
|
required: ['path'],
|
|
366
366
|
description:
|
|
367
|
-
'F009.125 — run the flow in a Chromium with this UNPACKED extension loaded (the build dir holding manifest.json; relative = relative to local_path; must be inside the daemon sandbox). Then: {type:"openPopup", path?} opens the popup (default: the manifest action.default_popup) as its own page and keeps the page under test as the extension\'s ACTIVE tab (measured), and any step with in:"popup" runs in the popup. grant_host_access:true loads a TEMPORARY COPY with <all_urls> host access, standing in for the toolbar click (activeTab) a headless run cannot make — without it chrome.scripting on the page fails ("Cannot access contents of the page"). The original build is never written; the report carries extension {id, host_access:"simulated"|"as-built"}. CHROMIUM ONLY.',
|
|
367
|
+
'F009.125 — run the flow in a Chromium with this UNPACKED extension loaded (the build dir holding manifest.json; relative = relative to local_path; must be inside the daemon sandbox). Then: {type:"openPopup", path?} opens the popup (default: the manifest action.default_popup) as its own page and keeps the page under test as the extension\'s ACTIVE tab (measured), and any step with in:"popup" runs in the popup. grant_host_access:true loads a TEMPORARY COPY with <all_urls> host access, standing in for the toolbar click (activeTab) a headless run cannot make — without it chrome.scripting on the page fails ("Cannot access contents of the page"). The original build is never written; the report carries extension {id, host_access:"simulated"|"as-built"}. CHROMIUM ONLY. F009.126 — storage_local:{key: JSON} is written to the extension\'s chrome.storage.local before step 0 (read back and compared), so a popup that keeps its login there starts signed in; needs the "storage" permission in the manifest. Pair with {type:"expectRequest", url, match?:"contains"|"regex", method?, status?} to assert a request the page, popup or extension worker made (fails listing what was seen).',
|
|
368
368
|
properties: {
|
|
369
369
|
path: { type: 'string' },
|
|
370
370
|
grant_host_access: { type: 'boolean' },
|
|
371
|
+
storage_local: { type: 'object', description: 'F009.126 — {key: JSON value} for chrome.storage.local, set before step 0.' },
|
|
371
372
|
},
|
|
372
373
|
},
|
|
373
374
|
steps: {
|
|
374
375
|
type: 'array',
|
|
375
376
|
description: 'Ordered steps. Each has a "type"; testid-anchored steps carry a "testid". fillSelector/clickSelector carry a CSS "selector" + optional "frame" (one iframe selector or an outer→inner string[] chain) to reach a cross-origin iframe; fillSelector also carries "value". waitForUrl carries a "url" substring. A "loop" step has data[] + steps[] and substitutes {{field}}/{{__index}}.',
|
|
376
|
-
items: { type: 'object', required: ['type'], properties: { type: { type: 'string' }, testid: { type: 'string' }, selector: { type: 'string' }, frame: { type: ['string', 'array'], items: { type: 'string' } }, url: { type: 'string' }, value: { type: 'string' }, text: { type: 'string' }, path: { type: 'string' }, js: { type: 'string' }, label: { type: 'string' }, timeout: { type: 'number' }, sha: { type: 'string', description: 'F255.7 — waitForBuild step: the commit sha to wait for. Omit to wait for any change from the build live when the step starts.' }, at: { type: 'string', description: 'F255.4 — click a POINT inside the element instead of its centre: "50%,28%" (fraction of the box) or "40,120" (css px). For canvas/3D surfaces where the centre hits nothing. Works on click + clickSelector.' }, activate: { type: 'string', enum: ['auto', 'click', 'enter'], description: 'F255.1 — how a button is fired. auto (default) clicks and falls back to focus+Enter only if nothing observable happened; enter forces focus+Enter (Angular/MDC forms); click keeps mouse-only.' }, in: { type: 'string', enum: ['popup'], description: 'F009.125 — run this step in the extension popup an earlier openPopup opened (needs flow.extension).' } } },
|
|
377
|
+
items: { type: 'object', required: ['type'], properties: { type: { type: 'string' }, testid: { type: 'string' }, selector: { type: 'string' }, frame: { type: ['string', 'array'], items: { type: 'string' } }, url: { type: 'string' }, value: { type: 'string' }, text: { type: 'string' }, path: { type: 'string' }, js: { type: 'string' }, label: { type: 'string' }, timeout: { type: 'number' }, sha: { type: 'string', description: 'F255.7 — waitForBuild step: the commit sha to wait for. Omit to wait for any change from the build live when the step starts.' }, at: { type: 'string', description: 'F255.4 — click a POINT inside the element instead of its centre: "50%,28%" (fraction of the box) or "40,120" (css px). For canvas/3D surfaces where the centre hits nothing. Works on click + clickSelector.' }, activate: { type: 'string', enum: ['auto', 'click', 'enter'], description: 'F255.1 — how a button is fired. auto (default) clicks and falls back to focus+Enter only if nothing observable happened; enter forces focus+Enter (Angular/MDC forms); click keeps mouse-only.' }, in: { type: 'string', enum: ['popup'], description: 'F009.125 — run this step in the extension popup an earlier openPopup opened (needs flow.extension).' }, method: { type: 'string', description: 'F009.126 — expectRequest: HTTP method to require, e.g. "POST".' }, status: { type: 'number', description: 'F009.126 — expectRequest: response status to require, e.g. 201.' } } },
|
|
377
378
|
},
|
|
378
379
|
},
|
|
379
380
|
},
|
package/src/validate-vision.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// MISTRAL_API_KEY auto-loaded from .env:
|
|
12
12
|
// bun run apps/agent/src/lens/validate-vision.ts
|
|
13
13
|
|
|
14
|
+
import { AI_AGENT_NAME } from '@broberg/lens-engine';
|
|
14
15
|
import { withPage } from './browser.ts';
|
|
15
16
|
|
|
16
17
|
const FIXTURES = [
|
|
@@ -58,7 +59,7 @@ const sdk = await import('@broberg/ai-sdk');
|
|
|
58
59
|
const providers: Record<string, unknown> = {};
|
|
59
60
|
if (process.env.MISTRAL_API_KEY) providers.mistral = sdk.mistralAdapter({ apiKey: process.env.MISTRAL_API_KEY });
|
|
60
61
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
|
-
const ai = sdk.createAI({ providers: providers as any }) as any;
|
|
62
|
+
const ai = sdk.createAI({ providers: providers as any, agentName: AI_AGENT_NAME }) as any;
|
|
62
63
|
|
|
63
64
|
for (const fx of FIXTURES) {
|
|
64
65
|
const url = 'data:text/html;charset=utf-8,' + encodeURIComponent(fx.html);
|