@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0-beta.160.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 +433 -133
- package/config/notis_app_boundary_rules.json +50 -0
- package/config/notis_app_design_rules.json +135 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +18672 -0
- package/dist/base-skills/notis-apps/SKILL.md +70 -0
- package/dist/base-skills/notis-apps/references/architecture.md +164 -0
- package/dist/base-skills/notis-apps/references/context.md +81 -0
- package/dist/base-skills/notis-apps/references/design.md +165 -0
- package/dist/base-skills/notis-apps/references/reading.md +89 -0
- package/dist/base-skills/notis-apps/references/release.md +99 -0
- package/dist/base-skills/notis-apps/references/sdk.md +62 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
- package/dist/base-skills/notis-cli/SKILL.md +140 -0
- package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +67 -0
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
- package/dist/base-skills/notis-query/references/documents.md +50 -0
- package/dist/base-skills/notis-query/references/query.md +543 -0
- package/dist/skill-sync/index.js +1626 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/dist/skill-sync-worker.mjs +2990 -0
- package/package.json +16 -6
- package/skills/notis-apps/cli.md +313 -0
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +129 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +294 -25
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1470 -202
- package/src/command-specs/auth.js +114 -137
- package/src/command-specs/diagnostics.js +716 -0
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/helpers.js +84 -82
- package/src/command-specs/index.js +25 -6
- package/src/command-specs/meta.js +150 -18
- package/src/command-specs/onboarding.js +290 -0
- package/src/command-specs/profile.js +358 -0
- package/src/command-specs/reports.js +86 -0
- package/src/command-specs/skills.js +75 -0
- package/src/command-specs/smoke.js +386 -0
- package/src/command-specs/tools.js +455 -139
- package/src/runtime/agent-browser.js +632 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/app-boundary-validator.js +404 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-platform.js +2633 -210
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/app-test-server.js +292 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/auth-recovery.js +110 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/channel.js +133 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/errors.js +1 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/oauth.js +2622 -0
- package/src/runtime/output.js +37 -5
- package/src/runtime/ports.js +31 -0
- package/src/runtime/profiles.js +906 -55
- package/src/runtime/skill-sync/cloud-client.ts +99 -0
- package/src/runtime/skill-sync/index.ts +697 -0
- package/src/runtime/skill-sync/local-scanner.ts +1046 -0
- package/src/runtime/skill-sync/symlink-manager.ts +433 -0
- package/src/runtime/skill-sync/sync-plan.ts +22 -0
- package/src/runtime/skill-sync/types.ts +110 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/runtime/store-screenshot.js +143 -0
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +231 -0
- package/src/runtime/telemetry.js +92 -0
- package/src/runtime/transport.js +324 -45
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/template/.harness/index.html.tmpl +430 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/layout.tsx +5 -2
- package/template/app/page.tsx +49 -42
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +24 -6
- package/template/package-lock.json +4137 -0
- package/template/package.json +5 -5
- package/template/packages/{notis-sdk → sdk}/package.json +13 -3
- package/template/packages/sdk/src/agentContext.ts +36 -0
- package/template/packages/sdk/src/components/DocumentEditor.tsx +103 -0
- package/template/packages/sdk/src/components/Markdown.tsx +60 -0
- package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +285 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/sdk/src/components/NotisCommentBoundary.tsx +172 -0
- package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
- package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
- package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
- package/template/packages/sdk/src/config.ts +257 -0
- package/template/packages/sdk/src/documents.ts +256 -0
- package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
- package/template/packages/sdk/src/hooks/useAgentContext.ts +23 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +64 -0
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +836 -0
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +49 -0
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/template/packages/sdk/src/hooks/useDocument.ts +43 -0
- package/template/packages/sdk/src/hooks/useDocuments.ts +84 -0
- package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
- package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +95 -0
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +10 -4
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +11 -8
- package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
- package/template/packages/sdk/src/hooks/useTool.ts +65 -0
- package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +81 -0
- package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/template/packages/sdk/src/index.ts +161 -0
- package/template/packages/sdk/src/interactions/actions.ts +59 -0
- package/template/packages/sdk/src/interactions/shortcuts.tsx +694 -0
- package/template/packages/sdk/src/interactions/visibility.ts +13 -0
- package/template/packages/sdk/src/interactions.ts +45 -0
- package/template/packages/sdk/src/provider.tsx +44 -0
- package/template/packages/sdk/src/queryCache.ts +170 -0
- package/template/packages/sdk/src/runtime.ts +451 -0
- package/template/packages/sdk/src/styles.css +213 -0
- package/template/packages/sdk/src/tailwind.ts +56 -0
- package/template/packages/{notis-sdk → sdk}/src/vite.ts +5 -1
- package/template/tailwind.config.ts +1 -0
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -312
- package/template/packages/notis-sdk/src/config.ts +0 -48
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -159
- package/template/packages/notis-sdk/src/styles.css +0 -123
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
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; })()";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Prepare the mounted harness page for a listing capture: remove the debug
|
|
10
|
+
* banner and the layout the harness adds around the app (top padding for the
|
|
11
|
+
* banner, 100vh min-height) so content height can be measured and framed
|
|
12
|
+
* without artificial whitespace.
|
|
13
|
+
*/
|
|
14
|
+
const PREPARE_CAPTURE_SCRIPT =
|
|
15
|
+
"(() => { const s = document.getElementById('harness-status'); if (s) s.style.display = 'none';" +
|
|
16
|
+
" const r = document.getElementById('root'); if (r) { r.style.paddingTop = '0'; r.style.minHeight = '0'; }" +
|
|
17
|
+
" document.body.style.minHeight = '0'; document.documentElement.style.minHeight = '0'; return true; })()";
|
|
18
|
+
|
|
19
|
+
const FONTS_STATUS_SCRIPT =
|
|
20
|
+
"(() => (document.fonts ? document.fonts.status : 'loaded'))()";
|
|
21
|
+
|
|
22
|
+
const CONTENT_HEIGHT_SCRIPT =
|
|
23
|
+
'(() => { const r = document.getElementById(\'root\');' +
|
|
24
|
+
' if (!r) return Math.ceil(document.documentElement.scrollHeight);' +
|
|
25
|
+
' return Math.ceil(Math.max(r.scrollHeight, r.getBoundingClientRect().height)); })()';
|
|
26
|
+
|
|
27
|
+
// CSS viewport bounds for content-aware framing. Content shorter than the
|
|
28
|
+
// minimum gets a little breathing room; content taller than the maximum is
|
|
29
|
+
// cropped at the frame rather than shrunk into unreadably small text.
|
|
30
|
+
const MIN_CSS_VIEWPORT_HEIGHT = 720;
|
|
31
|
+
const MAX_CSS_VIEWPORT_HEIGHT = 1500;
|
|
32
|
+
|
|
33
|
+
function pngDimensions(path) {
|
|
34
|
+
try {
|
|
35
|
+
const buffer = readFileSync(path);
|
|
36
|
+
if (buffer.length < 24 || buffer.readUInt32BE(12) !== 0x49484452) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pick a CSS viewport that frames `contentHeight` at the output aspect ratio,
|
|
47
|
+
* with a deviceScaleFactor that maps it back to exactly outputWidth x
|
|
48
|
+
* outputHeight physical pixels. Width snaps to a multiple of 8 so the height
|
|
49
|
+
* stays integral for the common 16:10 target.
|
|
50
|
+
*/
|
|
51
|
+
function fitCssViewport(contentHeight, outputWidth, outputHeight) {
|
|
52
|
+
const aspect = outputWidth / outputHeight;
|
|
53
|
+
const cssH = Math.min(
|
|
54
|
+
MAX_CSS_VIEWPORT_HEIGHT,
|
|
55
|
+
Math.max(MIN_CSS_VIEWPORT_HEIGHT, Math.round(contentHeight)),
|
|
56
|
+
);
|
|
57
|
+
const cssW = Math.round((cssH * aspect) / 8) * 8;
|
|
58
|
+
return {
|
|
59
|
+
width: cssW,
|
|
60
|
+
height: Math.round(cssW / aspect),
|
|
61
|
+
scale: outputWidth / cssW,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function evalNumber(sessionName, script, fallback = null) {
|
|
66
|
+
const result = await runAgentBrowser(
|
|
67
|
+
['--session', sessionName, '--json', 'eval', script],
|
|
68
|
+
{ timeoutMs: 5000 },
|
|
69
|
+
);
|
|
70
|
+
if (result.exitCode !== 0) {
|
|
71
|
+
return fallback;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const payload = parseAgentBrowserJson(result.stdout);
|
|
75
|
+
const raw = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
|
|
76
|
+
const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
77
|
+
return Number.isFinite(value) ? value : fallback;
|
|
78
|
+
} catch {
|
|
79
|
+
return fallback;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function waitForFonts(sessionName, timeoutMs = 3000) {
|
|
84
|
+
const deadline = Date.now() + timeoutMs;
|
|
85
|
+
while (Date.now() < deadline) {
|
|
86
|
+
const result = await runAgentBrowser(
|
|
87
|
+
['--session', sessionName, '--json', 'eval', FONTS_STATUS_SCRIPT],
|
|
88
|
+
{ timeoutMs: 5000 },
|
|
89
|
+
);
|
|
90
|
+
if (result.exitCode !== 0) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const payload = parseAgentBrowserJson(result.stdout);
|
|
95
|
+
const raw = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
|
|
96
|
+
const status = typeof raw === 'string' && raw.startsWith('"') ? JSON.parse(raw) : raw;
|
|
97
|
+
if (status !== 'loading') {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
await delay(150);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function setViewport(sessionName, width, height, scale = null) {
|
|
108
|
+
const args = ['--session', sessionName, 'set', 'viewport', String(width), String(height)];
|
|
109
|
+
if (scale != null) {
|
|
110
|
+
args.push(String(scale));
|
|
111
|
+
}
|
|
112
|
+
const result = await runAgentBrowser(args, { timeoutMs: 5000 });
|
|
113
|
+
return result.exitCode === 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function delay(ms) {
|
|
117
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function commandError(phase, result) {
|
|
121
|
+
return {
|
|
122
|
+
phase,
|
|
123
|
+
exit_code: result.exitCode,
|
|
124
|
+
stdout: result.stdout.trim(),
|
|
125
|
+
stderr: result.stderr.trim(),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function runAgentBrowser(args, { timeoutMs = 30_000 } = {}) {
|
|
130
|
+
return new Promise((resolvePromise) => {
|
|
131
|
+
const child = spawn('agent-browser', args, {
|
|
132
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
133
|
+
env: process.env,
|
|
134
|
+
});
|
|
135
|
+
let stdout = '';
|
|
136
|
+
let stderr = '';
|
|
137
|
+
let timedOut = false;
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
timedOut = true;
|
|
140
|
+
child.kill('SIGTERM');
|
|
141
|
+
}, timeoutMs);
|
|
142
|
+
|
|
143
|
+
child.stdout.on('data', (chunk) => {
|
|
144
|
+
stdout += chunk.toString();
|
|
145
|
+
});
|
|
146
|
+
child.stderr.on('data', (chunk) => {
|
|
147
|
+
stderr += chunk.toString();
|
|
148
|
+
});
|
|
149
|
+
child.on('error', (error) => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
resolvePromise({
|
|
152
|
+
exitCode: 1,
|
|
153
|
+
stdout,
|
|
154
|
+
stderr: stderr || error.message,
|
|
155
|
+
timedOut,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
child.on('close', (code) => {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
resolvePromise({
|
|
161
|
+
exitCode: timedOut ? 124 : (code ?? 1),
|
|
162
|
+
stdout,
|
|
163
|
+
stderr,
|
|
164
|
+
timedOut,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseAgentBrowserJson(stdout) {
|
|
171
|
+
const trimmed = stdout.trim();
|
|
172
|
+
if (!trimmed) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
return JSON.parse(trimmed);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseHarnessFromEval(stdout) {
|
|
179
|
+
const payload = parseAgentBrowserJson(stdout);
|
|
180
|
+
const rawResult = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
|
|
181
|
+
if (rawResult == null || rawResult === 'null') {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return typeof rawResult === 'string' ? JSON.parse(rawResult) : rawResult;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function readHarness(sessionName, timeoutMs) {
|
|
188
|
+
const result = await runAgentBrowser(
|
|
189
|
+
[
|
|
190
|
+
'--session',
|
|
191
|
+
sessionName,
|
|
192
|
+
'--json',
|
|
193
|
+
'eval',
|
|
194
|
+
'JSON.stringify(window.__harness || null)',
|
|
195
|
+
],
|
|
196
|
+
{ timeoutMs },
|
|
197
|
+
);
|
|
198
|
+
if (result.exitCode !== 0) {
|
|
199
|
+
return { ok: false, toolError: commandError('eval', result) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
return { ok: true, harness: parseHarnessFromEval(result.stdout) };
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
toolError: {
|
|
208
|
+
phase: 'eval_parse',
|
|
209
|
+
message: error instanceof Error ? error.message : String(error),
|
|
210
|
+
stdout: result.stdout.trim(),
|
|
211
|
+
stderr: result.stderr.trim(),
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function isAgentBrowserAvailable() {
|
|
218
|
+
const result = spawnSync('agent-browser', ['--version'], {
|
|
219
|
+
stdio: 'ignore',
|
|
220
|
+
env: process.env,
|
|
221
|
+
});
|
|
222
|
+
return result.status === 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function runHarnessRoute({
|
|
226
|
+
url,
|
|
227
|
+
sessionName,
|
|
228
|
+
timeoutMs = 10_000,
|
|
229
|
+
snapshotPath = null,
|
|
230
|
+
designViewports = DEFAULT_DESIGN_VIEWPORTS,
|
|
231
|
+
}) {
|
|
232
|
+
const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
|
|
233
|
+
timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
|
|
234
|
+
});
|
|
235
|
+
if (opened.exitCode !== 0) {
|
|
236
|
+
return {
|
|
237
|
+
mounted: false,
|
|
238
|
+
renderStarted: false,
|
|
239
|
+
errors: [],
|
|
240
|
+
runtimeCalls: [],
|
|
241
|
+
snapshotPath: null,
|
|
242
|
+
tool_error: commandError('open', opened),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const deadline = Date.now() + timeoutMs;
|
|
247
|
+
while (Date.now() < deadline) {
|
|
248
|
+
const lastRead = await readHarness(sessionName, 5000);
|
|
249
|
+
if (!lastRead.ok) {
|
|
250
|
+
await delay(250);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const harness = lastRead.harness;
|
|
254
|
+
if (harness?.mounted || (Array.isArray(harness?.errors) && harness.errors.length > 0)) {
|
|
255
|
+
await delay(250);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
await delay(250);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const finalRead = await readHarness(sessionName, 5000);
|
|
262
|
+
if (!finalRead.ok) {
|
|
263
|
+
return {
|
|
264
|
+
mounted: false,
|
|
265
|
+
renderStarted: false,
|
|
266
|
+
errors: [],
|
|
267
|
+
runtimeCalls: [],
|
|
268
|
+
snapshotPath: null,
|
|
269
|
+
tool_error: finalRead.toolError,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const harness = finalRead.harness || {};
|
|
274
|
+
let savedSnapshotPath = null;
|
|
275
|
+
if (snapshotPath) {
|
|
276
|
+
const snapshot = await runAgentBrowser(['--session', sessionName, 'snapshot', '-i'], {
|
|
277
|
+
timeoutMs: 10_000,
|
|
278
|
+
});
|
|
279
|
+
if (snapshot.exitCode === 0) {
|
|
280
|
+
mkdirSync(dirname(snapshotPath), { recursive: true });
|
|
281
|
+
writeFileSync(snapshotPath, snapshot.stdout);
|
|
282
|
+
savedSnapshotPath = snapshotPath;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const timedOut = !harness.mounted && Date.now() >= deadline;
|
|
287
|
+
const runtimeCalls = Array.isArray(harness.runtimeCalls) ? harness.runtimeCalls : [];
|
|
288
|
+
|
|
289
|
+
// Design assertions only mean something on a mounted route. Loading
|
|
290
|
+
// placeholders are excused while a runtime call is still in flight.
|
|
291
|
+
let design = [];
|
|
292
|
+
let designToolError = null;
|
|
293
|
+
if (harness.mounted && Array.isArray(designViewports) && designViewports.length > 0) {
|
|
294
|
+
await delay(500);
|
|
295
|
+
const pending = runtimeCalls.some((call) => call && call.ok == null);
|
|
296
|
+
const collected = await collectDesignFindings(sessionName, {
|
|
297
|
+
viewports: designViewports,
|
|
298
|
+
skipLoadingCheck: pending,
|
|
299
|
+
});
|
|
300
|
+
design = collected.findings;
|
|
301
|
+
designToolError = collected.tool_error;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
mounted: Boolean(harness.mounted),
|
|
306
|
+
renderStarted: Boolean(harness.renderStarted),
|
|
307
|
+
errors: Array.isArray(harness.errors) ? harness.errors : [],
|
|
308
|
+
runtimeCalls,
|
|
309
|
+
snapshotPath: savedSnapshotPath,
|
|
310
|
+
timed_out: timedOut,
|
|
311
|
+
tool_error: null,
|
|
312
|
+
design,
|
|
313
|
+
design_tool_error: designToolError,
|
|
314
|
+
raw: harness,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Open a harness route, wait for it to mount, then capture a PNG screenshot of
|
|
320
|
+
* the rendered app. Output is always exactly `width` x `height` physical
|
|
321
|
+
* pixels (default 2000x1250, the 16:10 the listing validator expects), but the
|
|
322
|
+
* CSS viewport is fitted to the rendered content height: short pages render
|
|
323
|
+
* larger (no dead whitespace below the app), tall pages get more room before
|
|
324
|
+
* cropping. The deviceScaleFactor maps the fitted CSS viewport back onto the
|
|
325
|
+
* fixed output frame. Used by `notis apps screenshot`.
|
|
326
|
+
*/
|
|
327
|
+
export async function captureHarnessScreenshot({
|
|
328
|
+
url,
|
|
329
|
+
sessionName,
|
|
330
|
+
screenshotPath,
|
|
331
|
+
focusSelector = null,
|
|
332
|
+
width = 2000,
|
|
333
|
+
height = 1250,
|
|
334
|
+
timeoutMs = 15_000,
|
|
335
|
+
}) {
|
|
336
|
+
const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
|
|
337
|
+
timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
|
|
338
|
+
});
|
|
339
|
+
if (opened.exitCode !== 0) {
|
|
340
|
+
return { ok: false, mounted: false, screenshotPath: null, tool_error: commandError('open', opened) };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Initial fixed frame so mount/layout happen at a deterministic size; the
|
|
344
|
+
// scale keeps physical output at width x height from the very first paint.
|
|
345
|
+
const initial = fitCssViewport(900, width, height);
|
|
346
|
+
let scaledViewport = await setViewport(
|
|
347
|
+
sessionName,
|
|
348
|
+
initial.width,
|
|
349
|
+
initial.height,
|
|
350
|
+
initial.scale,
|
|
351
|
+
);
|
|
352
|
+
if (!scaledViewport) {
|
|
353
|
+
// Older agent-browser without deviceScaleFactor support: fall back to a
|
|
354
|
+
// plain fixed viewport at output size.
|
|
355
|
+
await setViewport(sessionName, width, height);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const deadline = Date.now() + timeoutMs;
|
|
359
|
+
let lastErrors = [];
|
|
360
|
+
let lastReadError = null;
|
|
361
|
+
let mounted = false;
|
|
362
|
+
while (Date.now() < deadline) {
|
|
363
|
+
const read = await readHarness(sessionName, 5000);
|
|
364
|
+
if (read.ok) {
|
|
365
|
+
const harness = read.harness || {};
|
|
366
|
+
mounted = Boolean(harness.mounted);
|
|
367
|
+
lastErrors = Array.isArray(harness.errors) ? harness.errors : [];
|
|
368
|
+
if (mounted || lastErrors.length > 0) {
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
lastReadError = read.toolError;
|
|
373
|
+
}
|
|
374
|
+
await delay(250);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (lastErrors.length > 0) {
|
|
378
|
+
return { ok: false, mounted, screenshotPath: null, errors: lastErrors, tool_error: null };
|
|
379
|
+
}
|
|
380
|
+
if (!mounted) {
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
mounted: false,
|
|
384
|
+
screenshotPath: null,
|
|
385
|
+
errors: [],
|
|
386
|
+
timed_out: true,
|
|
387
|
+
tool_error: lastReadError || {
|
|
388
|
+
phase: 'harness',
|
|
389
|
+
message: 'Timed out waiting for window.__harness.mounted.',
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Strip harness chrome (debug banner, banner padding, 100vh min-height) so
|
|
395
|
+
// the frame contains only the app, then wait for webfonts before measuring.
|
|
396
|
+
await runAgentBrowser(
|
|
397
|
+
['--session', sessionName, 'eval', PREPARE_CAPTURE_SCRIPT],
|
|
398
|
+
{ timeoutMs: 5000 },
|
|
399
|
+
);
|
|
400
|
+
await waitForFonts(sessionName);
|
|
401
|
+
|
|
402
|
+
// Fit the CSS viewport to the rendered content. Content height depends on
|
|
403
|
+
// viewport width, so refine once after the first resize reflows the page.
|
|
404
|
+
let framing = null;
|
|
405
|
+
if (scaledViewport) {
|
|
406
|
+
let contentHeight = await evalNumber(sessionName, CONTENT_HEIGHT_SCRIPT);
|
|
407
|
+
for (let pass = 0; contentHeight != null && pass < 2; pass += 1) {
|
|
408
|
+
const fitted = fitCssViewport(contentHeight, width, height);
|
|
409
|
+
if (framing && fitted.width === framing.width) {
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
scaledViewport = await setViewport(sessionName, fitted.width, fitted.height, fitted.scale);
|
|
413
|
+
if (!scaledViewport) {
|
|
414
|
+
framing = null;
|
|
415
|
+
await setViewport(sessionName, width, height);
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
framing = { ...fitted, content_height: contentHeight };
|
|
419
|
+
await delay(150);
|
|
420
|
+
const remeasured = await evalNumber(sessionName, CONTENT_HEIGHT_SCRIPT);
|
|
421
|
+
if (remeasured == null || Math.abs(remeasured - contentHeight) <= 32) {
|
|
422
|
+
framing.content_height = remeasured ?? contentHeight;
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
contentHeight = remeasured;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Let the route settle (async data, resize transitions) before the capture.
|
|
430
|
+
await delay(500);
|
|
431
|
+
const designResult = await evalDesignAssertions(sessionName, { viewport: 'capture' });
|
|
432
|
+
const design = Array.isArray(designResult) ? designResult : [];
|
|
433
|
+
|
|
434
|
+
mkdirSync(dirname(screenshotPath), { recursive: true });
|
|
435
|
+
const screenshotArgs = ['--session', sessionName, 'screenshot'];
|
|
436
|
+
if (focusSelector) {
|
|
437
|
+
screenshotArgs.push(focusSelector);
|
|
438
|
+
}
|
|
439
|
+
screenshotArgs.push(screenshotPath, '--screenshot-format', 'png');
|
|
440
|
+
const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15_000 });
|
|
441
|
+
if (shot.exitCode !== 0) {
|
|
442
|
+
return { ok: false, mounted: true, screenshotPath: null, errors: lastErrors, timed_out: false, tool_error: commandError('screenshot', shot) };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// The listing validator requires exact output dimensions. If the fitted
|
|
446
|
+
// capture came out wrong (e.g. the browser ignored the scale factor),
|
|
447
|
+
// recapture once at a plain fixed viewport.
|
|
448
|
+
const dimensions = pngDimensions(screenshotPath);
|
|
449
|
+
if (!focusSelector && framing && (!dimensions || dimensions.width !== width || dimensions.height !== height)) {
|
|
450
|
+
await setViewport(sessionName, width, height);
|
|
451
|
+
await delay(300);
|
|
452
|
+
const retry = await runAgentBrowser(
|
|
453
|
+
['--session', sessionName, 'screenshot', screenshotPath, '--screenshot-format', 'png'],
|
|
454
|
+
{ timeoutMs: 15_000 },
|
|
455
|
+
);
|
|
456
|
+
if (retry.exitCode !== 0) {
|
|
457
|
+
return { ok: false, mounted: true, screenshotPath: null, errors: lastErrors, timed_out: false, tool_error: commandError('screenshot', retry) };
|
|
458
|
+
}
|
|
459
|
+
framing = null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (focusSelector) {
|
|
463
|
+
framing = {
|
|
464
|
+
...(framing || {}),
|
|
465
|
+
focus_selector: focusSelector,
|
|
466
|
+
source_dimensions: dimensions,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
design,
|
|
472
|
+
ok: true,
|
|
473
|
+
mounted: true,
|
|
474
|
+
screenshotPath,
|
|
475
|
+
errors: lastErrors,
|
|
476
|
+
timed_out: false,
|
|
477
|
+
tool_error: null,
|
|
478
|
+
framing,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export async function closeAgentBrowserSession(sessionName) {
|
|
483
|
+
const result = await runAgentBrowser(['--session', sessionName, 'close'], {
|
|
484
|
+
timeoutMs: 5000,
|
|
485
|
+
});
|
|
486
|
+
return result.exitCode === 0;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
// Runtime design assertions.
|
|
491
|
+
//
|
|
492
|
+
// The static design lint (app-boundary-validator.js) catches banned class
|
|
493
|
+
// names. This script runs in the mounted harness page and catches what a
|
|
494
|
+
// regex cannot: a bordered box nested in a bordered box across files, tinted
|
|
495
|
+
// panels stacked three deep, text that computes below 12px, horizontal
|
|
496
|
+
// overflow at a phone width, and loading placeholders that outlive the data.
|
|
497
|
+
// Palette colors are deliberately not checked here: at runtime a token and a
|
|
498
|
+
// hardcoded hue resolve to the same RGB, so that stays with the static lint.
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
export const DESIGN_ASSERTIONS_MARKER = '__notisDesignAssertions';
|
|
501
|
+
|
|
502
|
+
export const DESIGN_ASSERTIONS_SCRIPT = `(() => {
|
|
503
|
+
/* ${DESIGN_ASSERTIONS_MARKER} */
|
|
504
|
+
const root = document.getElementById('root') || document.body;
|
|
505
|
+
const out = [];
|
|
506
|
+
const seen = new Set();
|
|
507
|
+
const push = (kind, el, extra) => {
|
|
508
|
+
const cls = String(el.className && el.className.baseVal !== undefined ? el.className.baseVal : el.className || '').trim().slice(0, 80);
|
|
509
|
+
const text = (el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 60);
|
|
510
|
+
const snippet = el.tagName.toLowerCase() + (cls ? '.' + cls.split(/\\s+/).slice(0, 6).join('.') : '') + (text ? ' "' + text + '"' : '');
|
|
511
|
+
const key = kind + '|' + snippet;
|
|
512
|
+
if (seen.has(key)) return;
|
|
513
|
+
seen.add(key);
|
|
514
|
+
out.push(Object.assign({ kind, snippet }, extra || {}));
|
|
515
|
+
};
|
|
516
|
+
const controls = new Set(['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'A', 'SUMMARY']);
|
|
517
|
+
const isControl = (el) => controls.has(el.tagName) || /^(button|checkbox|switch|radio|combobox|textbox|tab|menuitem)$/.test(el.getAttribute('role') || '');
|
|
518
|
+
const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
|
|
519
|
+
const isBox = (el) => {
|
|
520
|
+
const cs = getComputedStyle(el);
|
|
521
|
+
if (!['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'].every((k) => parseFloat(cs[k]) > 0)) return false;
|
|
522
|
+
if (/rgba\\(\\d+, \\d+, \\d+, 0\\)|transparent/.test(cs.borderTopColor)) return false;
|
|
523
|
+
const r = el.getBoundingClientRect();
|
|
524
|
+
return r.width > 40 && r.height > 20;
|
|
525
|
+
};
|
|
526
|
+
const bg = (el) => getComputedStyle(el).backgroundColor;
|
|
527
|
+
const transparent = (c) => !c || c === 'transparent' || /rgba\\(\\d+, \\d+, \\d+, 0\\)/.test(c);
|
|
528
|
+
const isPanel = (el) => {
|
|
529
|
+
if (transparent(bg(el))) return false;
|
|
530
|
+
if (parseFloat(getComputedStyle(el).borderRadius) <= 0) return false;
|
|
531
|
+
const r = el.getBoundingClientRect();
|
|
532
|
+
return r.width > 160 && r.height > 48;
|
|
533
|
+
};
|
|
534
|
+
const all = Array.from(root.querySelectorAll('*'));
|
|
535
|
+
for (const el of all) {
|
|
536
|
+
if (el.id === 'harness-status' || !visible(el) || isControl(el)) continue;
|
|
537
|
+
if (isBox(el)) {
|
|
538
|
+
let p = el.parentElement; let depth = 0;
|
|
539
|
+
while (p && p !== root && depth < 6) { if (!isControl(p) && isBox(p)) { push('nested_border_box', el); break; } p = p.parentElement; depth += 1; }
|
|
540
|
+
}
|
|
541
|
+
if (isPanel(el)) {
|
|
542
|
+
let p = el.parentElement; let panels = 0;
|
|
543
|
+
while (p && p !== root) { if (isPanel(p)) panels += 1; p = p.parentElement; }
|
|
544
|
+
if (panels >= 2) push('nested_tinted_panel', el, { depth: panels + 1 });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
548
|
+
let node;
|
|
549
|
+
while ((node = walker.nextNode())) {
|
|
550
|
+
const text = (node.textContent || '').trim();
|
|
551
|
+
if (!text) continue;
|
|
552
|
+
const el = node.parentElement;
|
|
553
|
+
if (!el || el.closest('#harness-status') || !visible(el)) continue;
|
|
554
|
+
const size = parseFloat(getComputedStyle(el).fontSize);
|
|
555
|
+
if (size > 0 && size < 12) push('text_below_12px', el, { font_size: size });
|
|
556
|
+
if (/^Loading(\\.{3}|\\u2026)?(\\s|$)/.test(text)) push('loading_placeholder_after_mount', el);
|
|
557
|
+
}
|
|
558
|
+
if (root.querySelector('[data-notis-content-skeleton]')) push('loading_placeholder_after_mount', root.querySelector('[data-notis-content-skeleton]'));
|
|
559
|
+
const doc = document.documentElement;
|
|
560
|
+
if (doc.scrollWidth > doc.clientWidth + 1) out.push({ kind: 'horizontal_overflow', snippet: 'document', scroll_width: doc.scrollWidth, client_width: doc.clientWidth });
|
|
561
|
+
return JSON.stringify(out.slice(0, 40));
|
|
562
|
+
})()`;
|
|
563
|
+
|
|
564
|
+
export const DEFAULT_DESIGN_VIEWPORTS = [
|
|
565
|
+
{ name: 'desktop', width: 1280, height: 900 },
|
|
566
|
+
{ name: 'mobile', width: 390, height: 844 },
|
|
567
|
+
];
|
|
568
|
+
|
|
569
|
+
const DESIGN_KIND_MESSAGES = {
|
|
570
|
+
nested_border_box: 'a bordered box sits inside another bordered box',
|
|
571
|
+
nested_tinted_panel: 'tinted panels are nested three deep',
|
|
572
|
+
text_below_12px: 'visible text renders below 12px',
|
|
573
|
+
loading_placeholder_after_mount: 'a loading placeholder is still visible after the route mounted with its data',
|
|
574
|
+
horizontal_overflow: 'the page overflows horizontally',
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
export function describeDesignFinding(finding) {
|
|
578
|
+
const base = DESIGN_KIND_MESSAGES[finding.kind] || finding.kind;
|
|
579
|
+
const where = finding.viewport ? ` at ${finding.viewport}` : '';
|
|
580
|
+
const detail = finding.snippet && finding.snippet !== 'document' ? `: ${finding.snippet}` : '';
|
|
581
|
+
return `${base}${where}${detail}`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Evaluate the design assertions in the current page. Returns an array of
|
|
586
|
+
* findings, or `{ tool_error }` when agent-browser could not run the script.
|
|
587
|
+
*/
|
|
588
|
+
export async function evalDesignAssertions(sessionName, { viewport = null, timeoutMs = 8000 } = {}) {
|
|
589
|
+
const result = await runAgentBrowser(
|
|
590
|
+
['--session', sessionName, '--json', 'eval', DESIGN_ASSERTIONS_SCRIPT],
|
|
591
|
+
{ timeoutMs },
|
|
592
|
+
);
|
|
593
|
+
if (result.exitCode !== 0) {
|
|
594
|
+
return { tool_error: commandError('design_eval', result) };
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
const payload = parseAgentBrowserJson(result.stdout);
|
|
598
|
+
const raw = payload?.data?.result ?? payload?.result ?? payload?.data ?? null;
|
|
599
|
+
const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
600
|
+
if (!Array.isArray(value)) throw new Error('Design evaluation did not return a findings array.');
|
|
601
|
+
return value.map((finding) => ({ ...finding, viewport: viewport || finding.viewport || null }));
|
|
602
|
+
} catch (error) {
|
|
603
|
+
return { tool_error: { phase: 'design_eval_parse', message: error.message } };
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Run every requested viewport. Missing browser checks are incomplete proof,
|
|
609
|
+
* not a passing design result, even when static source lint passed.
|
|
610
|
+
*/
|
|
611
|
+
export async function collectDesignFindings(sessionName, { viewports = DEFAULT_DESIGN_VIEWPORTS, settleMs = 350, skipLoadingCheck = false } = {}) {
|
|
612
|
+
const findings = [];
|
|
613
|
+
let toolError = null;
|
|
614
|
+
for (const viewport of viewports) {
|
|
615
|
+
const applied = await setViewport(sessionName, viewport.width, viewport.height);
|
|
616
|
+
if (!applied) {
|
|
617
|
+
toolError ||= { phase: 'design_viewport', message: `Could not set ${viewport.name} viewport.` };
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
await delay(settleMs);
|
|
621
|
+
const result = await evalDesignAssertions(sessionName, { viewport: viewport.name });
|
|
622
|
+
if (Array.isArray(result)) {
|
|
623
|
+
for (const finding of result) {
|
|
624
|
+
if (skipLoadingCheck && finding.kind === 'loading_placeholder_after_mount') continue;
|
|
625
|
+
findings.push(finding);
|
|
626
|
+
}
|
|
627
|
+
} else if (!toolError) {
|
|
628
|
+
toolError = result.tool_error;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return { findings, tool_error: toolError };
|
|
632
|
+
}
|