@notis_ai/cli 0.2.0-beta.178.1 → 0.2.0-beta.1792022492092.1
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/README.md +548 -98
- package/config/portal-route-roots.json +47 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +32901 -5934
- package/dist/base-skills/notis-apps/SKILL.md +76 -51
- package/dist/base-skills/notis-apps/references/architecture.md +116 -164
- package/dist/base-skills/notis-apps/references/design.md +26 -107
- package/dist/base-skills/notis-apps/references/memory.md +58 -0
- package/dist/base-skills/notis-apps/references/reading.md +52 -74
- package/dist/base-skills/notis-apps/references/release.md +105 -96
- package/dist/base-skills/notis-apps/references/sdk.md +115 -60
- package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
- package/dist/base-skills/notis-apps/references/views.md +145 -0
- package/dist/base-skills/notis-cli/SKILL.md +29 -12
- package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
- package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +6 -5
- package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
- package/dist/base-skills/notis-query/SKILL.md +34 -60
- package/dist/base-skills/notis-query/references/database-discovery.md +60 -59
- package/dist/base-skills/notis-query/references/documents.md +53 -39
- package/dist/base-skills/notis-query/references/query.md +57 -524
- package/dist/sdk/src/bundleModule.ts +79 -0
- package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
- package/dist/sdk/src/components/DocumentPage.tsx +144 -0
- package/dist/sdk/src/components/RecordComponents.tsx +33 -0
- package/dist/sdk/src/components/RenderChartData.tsx +19 -0
- package/dist/sdk/src/config.ts +4 -26
- package/dist/sdk/src/documents.ts +2 -0
- package/dist/sdk/src/hooks/useAction.ts +28 -0
- package/dist/sdk/src/hooks/useBackend.ts +3 -2
- package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
- package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
- package/dist/sdk/src/hooks/useCollectionInteractions.ts +4 -2
- package/dist/sdk/src/hooks/useHandover.ts +7 -7
- package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
- package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
- package/dist/sdk/src/hooks/useNotis.ts +3 -0
- package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
- package/dist/sdk/src/hooks/useQuery.ts +7 -4
- package/dist/sdk/src/hooks/useViewParams.ts +121 -0
- package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
- package/dist/sdk/src/index.ts +36 -0
- package/dist/sdk/src/interactions/shortcuts.tsx +42 -5
- package/dist/sdk/src/nativeData.ts +39 -0
- package/dist/sdk/src/presentation.tsx +82 -0
- package/dist/sdk/src/provider.tsx +9 -4
- package/dist/sdk/src/queryCache.ts +5 -3
- package/dist/sdk/src/queryErrors.ts +51 -0
- package/dist/sdk/src/runtime.ts +163 -13
- package/dist/sdk/src/space.ts +425 -0
- package/dist/sdk/src/styles.css +96 -72
- package/dist/sdk/src/vite.ts +11 -1
- package/dist/skill-sync/index.js +571 -80
- package/dist/skill-sync/index.js.map +4 -4
- package/dist/skill-sync-worker.mjs +858 -233
- package/dist/space-harness/host.js +66 -0
- package/dist/view-renderer/dist/host.css +1 -0
- package/dist/view-renderer/dist/host.js +1028 -0
- package/dist/view-renderer/dist/theme.css +73 -0
- package/dist/view-renderer/src/dom.js +180 -0
- package/dist/view-renderer/src/errors.js +8 -0
- package/dist/view-renderer/src/host.tsx +175 -0
- package/dist/view-renderer/src/index.js +4 -0
- package/dist/view-renderer/src/pool.js +76 -0
- package/dist/view-renderer/src/remote-broker.js +59 -0
- package/dist/view-renderer/src/renderer.js +201 -0
- package/package.json +16 -5
- package/skills/notis-apps/cli.md +323 -163
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +3 -3
- package/skills/notis-onboarding/BRIEF.md +7 -3
- package/src/command-specs/apps.js +2 -1
- package/src/command-specs/auth.js +2 -2
- package/src/command-specs/files.js +29 -0
- package/src/command-specs/handover.js +331 -79
- package/src/command-specs/helpers.js +2 -0
- package/src/command-specs/index.js +11 -5
- package/src/command-specs/profile.js +42 -1
- package/src/command-specs/skills.js +242 -1
- package/src/command-specs/spaces.js +645 -0
- package/src/command-specs/tools.js +0 -6
- package/src/command-specs/views.js +69 -0
- package/src/runtime/agent-browser.js +92 -29
- package/src/runtime/app-boundary-validator.js +7 -4
- package/src/runtime/app-data-literals.js +82 -0
- package/src/runtime/app-platform.js +321 -253
- package/src/runtime/computer-identity.d.ts +30 -0
- package/src/runtime/computer-identity.js +151 -0
- package/src/runtime/config-lock.js +135 -0
- package/src/runtime/confined-space-build.js +74 -0
- package/src/runtime/confined-space-verify.js +49 -0
- package/src/runtime/delegated-context.js +5 -21
- package/src/runtime/native-record-files.js +47 -0
- package/src/runtime/oauth.js +18 -1
- package/src/runtime/profiles.js +5 -107
- package/src/runtime/retired-sdk-files.js +20 -0
- package/src/runtime/screenshot-framing.js +85 -0
- package/src/runtime/skill-sync/cloud-client.ts +26 -4
- package/src/runtime/skill-sync/index.ts +230 -35
- package/src/runtime/skill-sync/local-scanner.ts +188 -29
- package/src/runtime/skill-sync/native-identity.ts +102 -0
- package/src/runtime/skill-sync/native-plan.ts +70 -0
- package/src/runtime/skill-sync/sync-plan.ts +16 -9
- package/src/runtime/skill-sync/types.ts +32 -6
- package/src/runtime/source-root.js +32 -0
- package/src/runtime/space-collection-scaffold.js +66 -0
- package/src/runtime/space-database-structures.js +229 -0
- package/src/runtime/space-fixture-broker.js +50 -0
- package/src/runtime/space-lock.js +337 -0
- package/src/runtime/space-platform.js +105 -0
- package/src/runtime/space-source.js +281 -0
- package/src/runtime/space-test-server.js +327 -0
- package/src/runtime/space-verification.js +89 -0
- package/src/runtime/space-verify-browser-worker.js +51 -0
- package/src/runtime/space-view-manifest.js +254 -0
- package/src/runtime/store-screenshot.js +10 -7
- package/src/runtime/sync-skills.d.ts +3 -1
- package/src/runtime/sync-skills.js +10 -2
- package/src/runtime/transport.js +8 -6
- package/src/runtime/view-render.js +49 -0
- package/src/skill-sync-worker.js +14 -2
- package/src/space-harness-host.ts +103 -0
- package/template/.harness/index.html.tmpl +7 -10
- package/template/app/globals.css +1 -0
- package/src/command-specs/reports.js +0 -97
|
@@ -2,7 +2,6 @@ import { accessSync, constants as fsConstants, createReadStream, existsSync, rea
|
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { basename } from 'node:path';
|
|
4
4
|
import { usageError } from '../runtime/errors.js';
|
|
5
|
-
import { assertToolNotDelegated } from '../runtime/delegated-context.js';
|
|
6
5
|
import {
|
|
7
6
|
COMPOSIO_GET_TOOL_SCHEMAS,
|
|
8
7
|
COMPOSIO_MULTI_EXECUTE_TOOL,
|
|
@@ -386,9 +385,6 @@ async function toolsDescribeHandler(ctx) {
|
|
|
386
385
|
|
|
387
386
|
async function toolsExecHandler(ctx) {
|
|
388
387
|
const requestedToolName = localNotisToolSlug(ctx.args.toolName);
|
|
389
|
-
// `tools exec` is the escape hatch around every first-class command, so the
|
|
390
|
-
// hand-over guard has to live here too or it guards nothing.
|
|
391
|
-
assertToolNotDelegated(requestedToolName);
|
|
392
388
|
let targetMutating = classifyToolMutation(requestedToolName);
|
|
393
389
|
ensureLongRunningToolTimeout(ctx, requestedToolName);
|
|
394
390
|
ctx.output.emitProgress({
|
|
@@ -504,8 +500,6 @@ async function toolsExecParallelHandler(ctx) {
|
|
|
504
500
|
if (!call.tool_name || typeof call.tool_name !== 'string') {
|
|
505
501
|
throw usageError(`calls[${i}] missing required "tool_name" string`);
|
|
506
502
|
}
|
|
507
|
-
// A batch is just as good an escape hatch as a single exec.
|
|
508
|
-
assertToolNotDelegated(localNotisToolSlug(call.tool_name));
|
|
509
503
|
}
|
|
510
504
|
ensureLongRunningToolTimeout(ctx, calls.map((call) => localNotisToolSlug(call.tool_name)));
|
|
511
505
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { httpRequest } from '../runtime/transport.js';
|
|
2
|
+
import { usageError } from '../runtime/errors.js';
|
|
3
|
+
import { renderViewWithCliRuntime } from '../runtime/view-render.js';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
export function viewRenderCommandSpec({ screenshotOnly = false } = {}) {
|
|
8
|
+
const command_path = screenshotOnly ? ['spaces', 'screenshot'] : ['views', 'render'];
|
|
9
|
+
return {
|
|
10
|
+
command_path, summary: screenshotOnly ? 'Capture a full-height PNG of a live view with your current access.' : 'Render a live view as Markdown and a full-height PNG with your current access.',
|
|
11
|
+
when_to_use: 'Check the actual deployed view; writes are blocked and current access is checked again before any output is saved.',
|
|
12
|
+
args_schema: { arguments: [{ token: '<url>', key: 'url', description: 'Current view-qualified or record link.' }], options: [
|
|
13
|
+
...(!screenshotOnly ? [{ flags: '--outputs <kinds>', description: 'Comma-separated markdown,screenshot (default both).' }] : []),
|
|
14
|
+
{ flags: '--width <pixels>', description: '1440 (default) or 390.' },
|
|
15
|
+
{ flags: '--output-dir <path>', description: 'New output directory; defaults to a unique .notis/renders directory. Existing files are never overwritten.' },
|
|
16
|
+
] }, examples: [screenshotOnly ? 'notis spaces screenshot <view-link> --width 390' : 'notis views render <view-link> --outputs markdown,screenshot'],
|
|
17
|
+
mutates: false, idempotent: true, require_auth: true,
|
|
18
|
+
backend_call: { type: 'http', path: '/portal_view_render/prepare' },
|
|
19
|
+
async handler(ctx) {
|
|
20
|
+
const outputs = screenshotOnly ? ['screenshot'] : (ctx.options.outputs || 'markdown,screenshot').split(',');
|
|
21
|
+
if (!outputs.length || new Set(outputs).size !== outputs.length || outputs.some(value => !['markdown', 'screenshot'].includes(value))) {
|
|
22
|
+
throw usageError('Choose markdown, screenshot, or markdown,screenshot.');
|
|
23
|
+
}
|
|
24
|
+
const width = ctx.options.width === undefined ? 1440 : Number(ctx.options.width);
|
|
25
|
+
const directory = resolve(ctx.options.outputDir || `.notis/renders/${randomUUID()}`);
|
|
26
|
+
const controller = new AbortController(), cancel = () => controller.abort();
|
|
27
|
+
process.once('SIGINT', cancel); process.once('SIGTERM', cancel);
|
|
28
|
+
try {
|
|
29
|
+
const { result, artifacts } = await renderViewWithCliRuntime({ runtime: ctx.runtime,
|
|
30
|
+
url: ctx.args.url, outputs, width, directory, signal: controller.signal });
|
|
31
|
+
const data = { ...result, artifacts };
|
|
32
|
+
if (data.screenshot) {
|
|
33
|
+
const { data_base64: _bytes, ...metadata } = data.screenshot;
|
|
34
|
+
data.screenshot = { ...metadata, path: artifacts.screenshot };
|
|
35
|
+
}
|
|
36
|
+
return ctx.output.emitSuccess({ command: command_path.join(' '), data,
|
|
37
|
+
humanSummary: `Rendered ${result.url}. Saved ${Object.values(artifacts).join(' and ')}.`, meta: { mutating: false } });
|
|
38
|
+
} finally { process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel); }
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const viewsCommandSpecs = [{
|
|
44
|
+
command_path: ['views', 'find'],
|
|
45
|
+
summary: 'Find the views you can open for a record, database, view link, Space or search.',
|
|
46
|
+
when_to_use: 'Get fresh view-qualified links and declared URL parameters before citing or opening a view.',
|
|
47
|
+
args_schema: { options: [
|
|
48
|
+
{ flags: '--record-key <id>', description: 'Find views that show this exact native record.' },
|
|
49
|
+
{ flags: '--database-id <id>', description: 'Find views over this database.' },
|
|
50
|
+
{ flags: '--url <url>', description: 'Resolve this view link and its current parameters.' },
|
|
51
|
+
{ flags: '--space-id <id>', description: 'Describe this Space view.' },
|
|
52
|
+
{ flags: '--query <text>', description: 'Search accessible view names, descriptions and readable context.' },
|
|
53
|
+
] },
|
|
54
|
+
examples: ['notis views find --record-key <record-key>', 'notis views find --url <view-link>', 'notis views find --query inbox'],
|
|
55
|
+
mutates: false, idempotent: true, require_auth: true,
|
|
56
|
+
backend_call: { type: 'http', path: '/portal_spaces/find-views' },
|
|
57
|
+
async handler(ctx) {
|
|
58
|
+
const options = { record_key: ctx.options.recordKey, database_id: ctx.options.databaseId,
|
|
59
|
+
url: ctx.options.url, space_id: ctx.options.spaceId, query: ctx.options.query };
|
|
60
|
+
const selected = Object.entries(options).filter(([, value]) => value !== undefined);
|
|
61
|
+
if (selected.length !== 1 || typeof selected[0][1] !== 'string' || !selected[0][1].trim()) {
|
|
62
|
+
throw usageError('Choose exactly one of --record-key, --database-id, --url, --space-id or --query.');
|
|
63
|
+
}
|
|
64
|
+
const result = await httpRequest({ runtime: ctx.runtime, method: 'POST', path: '/portal_spaces/find-views',
|
|
65
|
+
body: Object.fromEntries(selected), spacesProtocol: 1 });
|
|
66
|
+
return ctx.output.emitSuccess({ command: 'views find', data: result.payload,
|
|
67
|
+
humanSummary: JSON.stringify(result.payload, null, 2), meta: { mutating: false, request_id: result.requestId } });
|
|
68
|
+
},
|
|
69
|
+
}, viewRenderCommandSpec()];
|
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
|
-
|
|
5
|
-
export const HIDE_HARNESS_STATUS_SCRIPT =
|
|
6
|
-
"(() => { const el = document.getElementById('harness-status'); if (el) el.style.opacity = '0'; return true; })()";
|
|
4
|
+
import { contentCapturePlan, MEASURE_SCREENSHOT_CONTENT_SCRIPT, SAFE_VIEWPORT_HEIGHT_SCRIPT } from './screenshot-framing.js';
|
|
7
5
|
|
|
8
6
|
/**
|
|
9
|
-
* Prepare the mounted harness page for a listing capture: remove the
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* without artificial whitespace.
|
|
7
|
+
* Prepare the mounted harness page for a listing capture: remove the 100vh
|
|
8
|
+
* min-height the harness adds around the app so content height can be
|
|
9
|
+
* measured and framed without artificial whitespace.
|
|
13
10
|
*/
|
|
14
11
|
const PREPARE_CAPTURE_SCRIPT =
|
|
15
|
-
"(() => { const
|
|
16
|
-
" const r = document.getElementById('root'); if (r) { r.style.paddingTop = '0'; r.style.minHeight = '0'; }" +
|
|
12
|
+
"(() => { const r = document.getElementById('root'); if (r) r.style.minHeight = '0';" +
|
|
17
13
|
" document.body.style.minHeight = '0'; document.documentElement.style.minHeight = '0'; return true; })()";
|
|
18
14
|
|
|
19
15
|
const FONTS_STATUS_SCRIPT =
|
|
@@ -135,9 +131,11 @@ function runAgentBrowser(args, { timeoutMs = 30_000 } = {}) {
|
|
|
135
131
|
let stdout = '';
|
|
136
132
|
let stderr = '';
|
|
137
133
|
let timedOut = false;
|
|
134
|
+
let hardStop;
|
|
138
135
|
const timer = setTimeout(() => {
|
|
139
136
|
timedOut = true;
|
|
140
137
|
child.kill('SIGTERM');
|
|
138
|
+
hardStop = setTimeout(() => child.kill('SIGKILL'), 2000);
|
|
141
139
|
}, timeoutMs);
|
|
142
140
|
|
|
143
141
|
child.stdout.on('data', (chunk) => {
|
|
@@ -147,7 +145,7 @@ function runAgentBrowser(args, { timeoutMs = 30_000 } = {}) {
|
|
|
147
145
|
stderr += chunk.toString();
|
|
148
146
|
});
|
|
149
147
|
child.on('error', (error) => {
|
|
150
|
-
clearTimeout(timer);
|
|
148
|
+
clearTimeout(timer); clearTimeout(hardStop);
|
|
151
149
|
resolvePromise({
|
|
152
150
|
exitCode: 1,
|
|
153
151
|
stdout,
|
|
@@ -156,7 +154,7 @@ function runAgentBrowser(args, { timeoutMs = 30_000 } = {}) {
|
|
|
156
154
|
});
|
|
157
155
|
});
|
|
158
156
|
child.on('close', (code) => {
|
|
159
|
-
clearTimeout(timer);
|
|
157
|
+
clearTimeout(timer); clearTimeout(hardStop);
|
|
160
158
|
resolvePromise({
|
|
161
159
|
exitCode: timedOut ? 124 : (code ?? 1),
|
|
162
160
|
stdout,
|
|
@@ -229,10 +227,17 @@ export async function runHarnessRoute({
|
|
|
229
227
|
snapshotPath = null,
|
|
230
228
|
designViewports = DEFAULT_DESIGN_VIEWPORTS,
|
|
231
229
|
waitForRuntime = false,
|
|
230
|
+
signal,
|
|
232
231
|
}) {
|
|
232
|
+
const cancelled = () => ({ mounted: false, renderStarted: false, runtimeCalls: [],
|
|
233
|
+
errors: [{ phase: 'browser', message: 'Verification cancelled.' }], snapshotPath: null, tool_error: null });
|
|
234
|
+
if (signal?.aborted) return cancelled();
|
|
233
235
|
const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
|
|
234
236
|
timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
|
|
235
237
|
});
|
|
238
|
+
// Let an in-flight open settle before the owner closes its browser. Closing
|
|
239
|
+
// concurrently with open can leave a newly launched browser behind.
|
|
240
|
+
if (signal?.aborted) return cancelled();
|
|
236
241
|
if (opened.exitCode !== 0) {
|
|
237
242
|
return {
|
|
238
243
|
mounted: false,
|
|
@@ -246,7 +251,7 @@ export async function runHarnessRoute({
|
|
|
246
251
|
|
|
247
252
|
const deadline = Date.now() + timeoutMs;
|
|
248
253
|
let readySince = null;
|
|
249
|
-
while (Date.now() < deadline) {
|
|
254
|
+
while (Date.now() < deadline && !signal?.aborted) {
|
|
250
255
|
const lastRead = await readHarness(sessionName, 5000);
|
|
251
256
|
if (!lastRead.ok) {
|
|
252
257
|
readySince = null;
|
|
@@ -263,6 +268,8 @@ export async function runHarnessRoute({
|
|
|
263
268
|
await delay(250);
|
|
264
269
|
}
|
|
265
270
|
|
|
271
|
+
if (signal?.aborted) return cancelled();
|
|
272
|
+
|
|
266
273
|
const finalRead = await readHarness(sessionName, 5000);
|
|
267
274
|
if (!finalRead.ok) {
|
|
268
275
|
return {
|
|
@@ -322,18 +329,18 @@ export async function runHarnessRoute({
|
|
|
322
329
|
|
|
323
330
|
/**
|
|
324
331
|
* Open a harness route, wait for it to mount, then capture a PNG screenshot of
|
|
325
|
-
* the rendered app.
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
* fixed output frame. Used by `notis apps screenshot`.
|
|
332
|
+
* the rendered app. Raw captures retain exact requested pixel dimensions.
|
|
333
|
+
* Framed captures can select the complete bounded app or a width-fitted first
|
|
334
|
+
* screen; the Store compositor normalizes them to the final listing size.
|
|
335
|
+
* Device scaling keeps text sharp at that size without changing app styles.
|
|
336
|
+
* Used by `notis apps screenshot`.
|
|
331
337
|
*/
|
|
332
338
|
export async function captureHarnessScreenshot({
|
|
333
339
|
url,
|
|
334
340
|
sessionName,
|
|
335
341
|
screenshotPath,
|
|
336
342
|
focusSelector = null,
|
|
343
|
+
frameContent = false,
|
|
337
344
|
width = 2000,
|
|
338
345
|
height = 1250,
|
|
339
346
|
timeoutMs = 15_000,
|
|
@@ -433,13 +440,50 @@ export async function captureHarnessScreenshot({
|
|
|
433
440
|
|
|
434
441
|
// Let the route settle (async data, resize transitions) before the capture.
|
|
435
442
|
await delay(500);
|
|
443
|
+
|
|
444
|
+
let contentBounds = null;
|
|
445
|
+
if (frameContent && !focusSelector) {
|
|
446
|
+
const measured = await runAgentBrowser(
|
|
447
|
+
['--session', sessionName, '--json', 'eval', MEASURE_SCREENSHOT_CONTENT_SCRIPT],
|
|
448
|
+
{ timeoutMs: 5000 },
|
|
449
|
+
);
|
|
450
|
+
if (measured.exitCode === 0) {
|
|
451
|
+
try {
|
|
452
|
+
const bounds = parseHarnessFromEval(measured.stdout);
|
|
453
|
+
if (bounds?.selector === '[data-notis-screenshot-content]'
|
|
454
|
+
&& Number.isFinite(bounds.width) && bounds.width > 0
|
|
455
|
+
&& Number.isFinite(bounds.height) && bounds.height > 0) {
|
|
456
|
+
contentBounds = bounds;
|
|
457
|
+
}
|
|
458
|
+
} catch { /* Keep the complete viewport if layout measurement is unavailable. */ }
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
let captureFocus = focusSelector || contentBounds?.selector || null;
|
|
462
|
+
if (contentBounds && scaledViewport) {
|
|
463
|
+
const plan = contentCapturePlan(contentBounds, framing || initial, width, height);
|
|
464
|
+
if (await setViewport(sessionName, plan.width, plan.height, plan.scale)) {
|
|
465
|
+
await delay(300);
|
|
466
|
+
if (!plan.focus) {
|
|
467
|
+
const safeHeight = await evalNumber(sessionName, SAFE_VIEWPORT_HEIGHT_SCRIPT, plan.height);
|
|
468
|
+
if (safeHeight < plan.height && await setViewport(sessionName, plan.width, safeHeight, plan.scale)) {
|
|
469
|
+
plan.height = safeHeight;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
captureFocus = plan.focus ? contentBounds.selector : null;
|
|
473
|
+
framing = {
|
|
474
|
+
...(framing || {}), ...plan, content_bounds: contentBounds,
|
|
475
|
+
capture_mode: plan.focus ? 'content' : 'width-fit-viewport',
|
|
476
|
+
};
|
|
477
|
+
await delay(300);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
436
480
|
const designResult = await evalDesignAssertions(sessionName, { viewport: 'capture' });
|
|
437
481
|
const design = Array.isArray(designResult) ? designResult : [];
|
|
438
482
|
|
|
439
483
|
mkdirSync(dirname(screenshotPath), { recursive: true });
|
|
440
484
|
const screenshotArgs = ['--session', sessionName, 'screenshot'];
|
|
441
|
-
if (
|
|
442
|
-
screenshotArgs.push(
|
|
485
|
+
if (captureFocus) {
|
|
486
|
+
screenshotArgs.push(captureFocus);
|
|
443
487
|
}
|
|
444
488
|
screenshotArgs.push(screenshotPath, '--screenshot-format', 'png');
|
|
445
489
|
const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15_000 });
|
|
@@ -451,7 +495,13 @@ export async function captureHarnessScreenshot({
|
|
|
451
495
|
// capture came out wrong (e.g. the browser ignored the scale factor),
|
|
452
496
|
// recapture once at a plain fixed viewport.
|
|
453
497
|
const dimensions = pngDimensions(screenshotPath);
|
|
454
|
-
|
|
498
|
+
const expectedDimensions = framing?.capture_mode === 'width-fit-viewport'
|
|
499
|
+
? { width: Math.round(framing.width * framing.scale), height: Math.round(framing.height * framing.scale) }
|
|
500
|
+
: { width, height };
|
|
501
|
+
// Fractional browser scaling can round one pixel either way. Framed output
|
|
502
|
+
// is normalized by the compositor; raw captures still require exact pixels.
|
|
503
|
+
const tolerance = frameContent ? 1 : 0;
|
|
504
|
+
if (!captureFocus && framing && (!dimensions || Math.abs(dimensions.width - expectedDimensions.width) > tolerance || Math.abs(dimensions.height - expectedDimensions.height) > tolerance)) {
|
|
455
505
|
await setViewport(sessionName, width, height);
|
|
456
506
|
await delay(300);
|
|
457
507
|
const retry = await runAgentBrowser(
|
|
@@ -464,10 +514,11 @@ export async function captureHarnessScreenshot({
|
|
|
464
514
|
framing = null;
|
|
465
515
|
}
|
|
466
516
|
|
|
467
|
-
if (
|
|
517
|
+
if (captureFocus) {
|
|
468
518
|
framing = {
|
|
469
519
|
...(framing || {}),
|
|
470
|
-
focus_selector:
|
|
520
|
+
focus_selector: captureFocus,
|
|
521
|
+
content_bounds: contentBounds,
|
|
471
522
|
source_dimensions: dimensions,
|
|
472
523
|
};
|
|
473
524
|
}
|
|
@@ -485,10 +536,22 @@ export async function captureHarnessScreenshot({
|
|
|
485
536
|
}
|
|
486
537
|
|
|
487
538
|
export async function closeAgentBrowserSession(sessionName) {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
539
|
+
return (await closeAgentBrowserSessionResult(sessionName)).ok;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export async function closeAgentBrowserSessionResult(sessionName) {
|
|
543
|
+
const started = Date.now();
|
|
544
|
+
// agent-browser waits up to 30s for CDP Browser.close and then up to 5s
|
|
545
|
+
// for its owned Chrome process. Do not kill the client at the start of the
|
|
546
|
+
// provider's shutdown grace period and lose its actual close acknowledgement.
|
|
547
|
+
const result = await runAgentBrowser(['--session', sessionName, '--json', 'close'], { timeoutMs: 40_000 });
|
|
548
|
+
let acknowledged = false;
|
|
549
|
+
try {
|
|
550
|
+
const reply = parseAgentBrowserJson(result.stdout);
|
|
551
|
+
acknowledged = reply?.success === true && reply?.data?.closed === true;
|
|
552
|
+
} catch { /* Missing acknowledgement is a failed cleanup, never a pass. */ }
|
|
553
|
+
return { ok: result.exitCode === 0 && acknowledged, exit_code: result.exitCode,
|
|
554
|
+
timed_out: Boolean(result.timedOut), duration_ms: Date.now() - started };
|
|
492
555
|
}
|
|
493
556
|
|
|
494
557
|
// ---------------------------------------------------------------------------
|
|
@@ -538,7 +601,7 @@ export const DESIGN_ASSERTIONS_SCRIPT = `(() => {
|
|
|
538
601
|
};
|
|
539
602
|
const all = Array.from(root.querySelectorAll('*'));
|
|
540
603
|
for (const el of all) {
|
|
541
|
-
if (
|
|
604
|
+
if (!visible(el) || isControl(el)) continue;
|
|
542
605
|
if (isBox(el)) {
|
|
543
606
|
let p = el.parentElement; let depth = 0;
|
|
544
607
|
while (p && p !== root && depth < 6) { if (!isControl(p) && isBox(p)) { push('nested_border_box', el); break; } p = p.parentElement; depth += 1; }
|
|
@@ -555,7 +618,7 @@ export const DESIGN_ASSERTIONS_SCRIPT = `(() => {
|
|
|
555
618
|
const text = (node.textContent || '').trim();
|
|
556
619
|
if (!text) continue;
|
|
557
620
|
const el = node.parentElement;
|
|
558
|
-
if (!el ||
|
|
621
|
+
if (!el || !visible(el)) continue;
|
|
559
622
|
const size = parseFloat(getComputedStyle(el).fontSize);
|
|
560
623
|
if (size > 0 && size < 12) push('text_below_12px', el, { font_size: size });
|
|
561
624
|
if (/^Loading(\\.{3}|\\u2026)?(\\s|$)/.test(text)) push('loading_placeholder_after_mount', el);
|
|
@@ -3,6 +3,7 @@ import { dirname, extname, join, resolve } from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
5
5
|
import { usageError } from './errors.js';
|
|
6
|
+
import { maskSerializedJsonData } from './app-data-literals.js';
|
|
6
7
|
|
|
7
8
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
|
|
@@ -136,7 +137,7 @@ function validateTextFile(relPath, content) {
|
|
|
136
137
|
return applyRules(content, rules.css, relPath);
|
|
137
138
|
}
|
|
138
139
|
if (extension === '.js' || extension === '.jsx' || extension === '.ts' || extension === '.tsx') {
|
|
139
|
-
return applyRules(content, rules.javascript, relPath);
|
|
140
|
+
return applyRules(maskSerializedJsonData(content, extension), rules.javascript, relPath);
|
|
140
141
|
}
|
|
141
142
|
return [];
|
|
142
143
|
}
|
|
@@ -311,7 +312,9 @@ export function collectDesignViolationsForFile(relPath, content, config = getCom
|
|
|
311
312
|
if (!matchesAny(normalized, config.include)) return [];
|
|
312
313
|
if (matchesAny(normalized, config.exclude)) return [];
|
|
313
314
|
|
|
314
|
-
const
|
|
315
|
+
const checked = ['.js', '.jsx', '.ts', '.tsx'].includes(extname(normalized))
|
|
316
|
+
? maskSerializedJsonData(content, extname(normalized)) : content;
|
|
317
|
+
const lines = checked.split('\n');
|
|
315
318
|
const violations = [];
|
|
316
319
|
for (const rule of config.rules) {
|
|
317
320
|
if (rule.include && !matchesAny(normalized, rule.include)) continue;
|
|
@@ -319,12 +322,12 @@ export function collectDesignViolationsForFile(relPath, content, config = getCom
|
|
|
319
322
|
|
|
320
323
|
const matches = [];
|
|
321
324
|
if (rule.scope === 'file') {
|
|
322
|
-
const match = rule.regex.exec(
|
|
325
|
+
const match = rule.regex.exec(checked);
|
|
323
326
|
if (match) matches.push(match.index);
|
|
324
327
|
} else {
|
|
325
328
|
rule.regex.lastIndex = 0;
|
|
326
329
|
let match;
|
|
327
|
-
while ((match = rule.regex.exec(
|
|
330
|
+
while ((match = rule.regex.exec(checked)) !== null) {
|
|
328
331
|
matches.push(match.index);
|
|
329
332
|
if (match[0].length === 0) rule.regex.lastIndex += 1;
|
|
330
333
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
let babelParse;
|
|
5
|
+
|
|
6
|
+
function isJsonParse(node) {
|
|
7
|
+
const callee = node.callee;
|
|
8
|
+
return node.type === 'CallExpression' && callee?.type === 'MemberExpression' && !callee.computed
|
|
9
|
+
&& callee.object?.type === 'Identifier' && callee.object.name === 'JSON'
|
|
10
|
+
&& callee.property?.type === 'Identifier' && callee.property.name === 'parse';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function dataContext(ancestors) {
|
|
14
|
+
if (ancestors.some(node => node.type === 'JSXAttribute')) return false;
|
|
15
|
+
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
|
16
|
+
const node = ancestors[index];
|
|
17
|
+
if (node.type === 'JSXAttribute' || node.type === 'ImportDeclaration'
|
|
18
|
+
|| node.type === 'ImportExpression' || node.type === 'TSImportType'
|
|
19
|
+
|| node.type === 'ExportAllDeclaration' || node.type === 'ExportNamedDeclaration'
|
|
20
|
+
|| node.type === 'NewExpression' || node.type === 'TaggedTemplateExpression'
|
|
21
|
+
|| node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') return false;
|
|
22
|
+
if (node.type === 'CallExpression' || node.type === 'OptionalCallExpression') {
|
|
23
|
+
if (!isJsonParse(node)) return false;
|
|
24
|
+
}
|
|
25
|
+
if (['VariableDeclarator', 'ExportDefaultDeclaration', 'ReturnStatement'].includes(node.type)) return true;
|
|
26
|
+
if (node.type === 'ExpressionStatement') return false;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Serialized JSON is data, not source: a review may quote forbidden host code
|
|
33
|
+
* or historical styles. Mask only complete object/array JSON literal values in
|
|
34
|
+
* data-expression positions. Keep ordinary strings, import/call/selector and
|
|
35
|
+
* JSX attribute contexts, and every template expression under the existing
|
|
36
|
+
* rules. Parsing errors (or a missing parser) retain the original checks.
|
|
37
|
+
* Source/archive bytes are never rewritten; offsets and line breaks survive.
|
|
38
|
+
*/
|
|
39
|
+
export function maskSerializedJsonData(content, extension = '.js') {
|
|
40
|
+
let ast;
|
|
41
|
+
try {
|
|
42
|
+
babelParse ||= require('@babel/parser').parse;
|
|
43
|
+
ast = babelParse(content, {
|
|
44
|
+
sourceType: 'unambiguous',
|
|
45
|
+
allowReturnOutsideFunction: true,
|
|
46
|
+
plugins: [...(['.ts', '.tsx'].includes(extension) ? ['typescript'] : []),
|
|
47
|
+
...(extension !== '.ts' ? ['jsx'] : [])],
|
|
48
|
+
});
|
|
49
|
+
} catch { return content; }
|
|
50
|
+
const ranges = [];
|
|
51
|
+
const pending = [[ast, []]];
|
|
52
|
+
while (pending.length) {
|
|
53
|
+
const [node, ancestors] = pending.pop();
|
|
54
|
+
if (!node || typeof node !== 'object') continue;
|
|
55
|
+
const literal = node.type === 'StringLiteral' ? node.value
|
|
56
|
+
: node.type === 'TemplateLiteral' && node.expressions.length === 0 ? node.quasis[0]?.value.cooked : null;
|
|
57
|
+
if (typeof literal === 'string' && dataContext(ancestors)) {
|
|
58
|
+
try {
|
|
59
|
+
const value = JSON.parse(literal);
|
|
60
|
+
if (value !== null && typeof value === 'object') {
|
|
61
|
+
ranges.push([node.start, node.end]);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
} catch { /* Ordinary/non-JSON strings keep their original checks. */ }
|
|
65
|
+
}
|
|
66
|
+
const parents = [...ancestors, node];
|
|
67
|
+
for (const [key, value] of Object.entries(node)) {
|
|
68
|
+
if (['loc', 'extra', 'tokens', 'comments', 'errors'].includes(key)) continue;
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
for (const child of value) if (child?.type) pending.push([child, parents]);
|
|
71
|
+
} else if (value?.type) pending.push([value, parents]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
let cursor = 0;
|
|
75
|
+
const result = [];
|
|
76
|
+
for (const [start, end] of ranges.sort((a, b) => a[0] - b[0])) {
|
|
77
|
+
result.push(content.slice(cursor, start), content.slice(start, end).replace(/[^\r\n]/g, ' '));
|
|
78
|
+
cursor = end;
|
|
79
|
+
}
|
|
80
|
+
result.push(content.slice(cursor));
|
|
81
|
+
return result.join('');
|
|
82
|
+
}
|