@notis_ai/cli 0.2.0-beta.177.1 → 0.2.0-beta.179.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 +25 -39
- package/dist/agent-hooks/notis-agent-hook.mjs +315 -154
- package/dist/base-skills/notis-apps/SKILL.md +2 -0
- package/dist/base-skills/notis-apps/references/architecture.md +5 -2
- package/dist/base-skills/notis-apps/references/release.md +44 -0
- package/dist/sdk/src/config.ts +4 -0
- package/dist/sdk/src/hooks/useNotis.ts +3 -1
- package/dist/sdk/src/index.ts +1 -0
- package/dist/sdk/src/runtime.ts +13 -0
- package/package.json +1 -1
- package/skills/notis-apps/cli.md +23 -37
- package/src/command-specs/apps.js +20 -15
- package/src/command-specs/reports.js +61 -50
- package/src/runtime/agent-browser.js +66 -16
- package/src/runtime/app-platform.js +25 -1
- package/src/runtime/app-test-server.js +1 -0
- package/src/runtime/screenshot-framing.js +85 -0
- package/src/runtime/store-screenshot.js +10 -7
- package/template/.harness/index.html.tmpl +12 -4
|
@@ -1,6 +1,7 @@
|
|
|
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
|
+
import { contentCapturePlan, MEASURE_SCREENSHOT_CONTENT_SCRIPT, SAFE_VIEWPORT_HEIGHT_SCRIPT } from './screenshot-framing.js';
|
|
4
5
|
|
|
5
6
|
export const HIDE_HARNESS_STATUS_SCRIPT =
|
|
6
7
|
"(() => { const el = document.getElementById('harness-status'); if (el) el.style.opacity = '0'; return true; })()";
|
|
@@ -228,6 +229,7 @@ export async function runHarnessRoute({
|
|
|
228
229
|
timeoutMs = 10_000,
|
|
229
230
|
snapshotPath = null,
|
|
230
231
|
designViewports = DEFAULT_DESIGN_VIEWPORTS,
|
|
232
|
+
waitForRuntime = false,
|
|
231
233
|
}) {
|
|
232
234
|
const opened = await runAgentBrowser(['--session', sessionName, 'open', url], {
|
|
233
235
|
timeoutMs: Math.min(Math.max(timeoutMs, 5000), 30_000),
|
|
@@ -244,17 +246,21 @@ export async function runHarnessRoute({
|
|
|
244
246
|
}
|
|
245
247
|
|
|
246
248
|
const deadline = Date.now() + timeoutMs;
|
|
249
|
+
let readySince = null;
|
|
247
250
|
while (Date.now() < deadline) {
|
|
248
251
|
const lastRead = await readHarness(sessionName, 5000);
|
|
249
252
|
if (!lastRead.ok) {
|
|
253
|
+
readySince = null;
|
|
250
254
|
await delay(250);
|
|
251
255
|
continue;
|
|
252
256
|
}
|
|
253
257
|
const harness = lastRead.harness;
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
+
if (Array.isArray(harness?.errors) && harness.errors.length > 0) break;
|
|
259
|
+
const pending = (harness?.runtimeCalls || []).some(call => call?.ok == null);
|
|
260
|
+
if (harness?.mounted && (!waitForRuntime || !pending)) {
|
|
261
|
+
readySince ??= Date.now();
|
|
262
|
+
if (Date.now() - readySince >= 250) break;
|
|
263
|
+
} else readySince = null;
|
|
258
264
|
await delay(250);
|
|
259
265
|
}
|
|
260
266
|
|
|
@@ -283,8 +289,8 @@ export async function runHarnessRoute({
|
|
|
283
289
|
}
|
|
284
290
|
}
|
|
285
291
|
|
|
286
|
-
const timedOut = !harness.mounted && Date.now() >= deadline;
|
|
287
292
|
const runtimeCalls = Array.isArray(harness.runtimeCalls) ? harness.runtimeCalls : [];
|
|
293
|
+
const timedOut = (!harness.mounted || (waitForRuntime && runtimeCalls.some(call => call?.ok == null))) && Date.now() >= deadline;
|
|
288
294
|
|
|
289
295
|
// Design assertions only mean something on a mounted route. Loading
|
|
290
296
|
// placeholders are excused while a runtime call is still in flight.
|
|
@@ -317,18 +323,18 @@ export async function runHarnessRoute({
|
|
|
317
323
|
|
|
318
324
|
/**
|
|
319
325
|
* Open a harness route, wait for it to mount, then capture a PNG screenshot of
|
|
320
|
-
* the rendered app.
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* fixed output frame. Used by `notis apps screenshot`.
|
|
326
|
+
* the rendered app. Raw captures retain exact requested pixel dimensions.
|
|
327
|
+
* Framed captures can select the complete bounded app or a width-fitted first
|
|
328
|
+
* screen; the Store compositor normalizes them to the final listing size.
|
|
329
|
+
* Device scaling keeps text sharp at that size without changing app styles.
|
|
330
|
+
* Used by `notis apps screenshot`.
|
|
326
331
|
*/
|
|
327
332
|
export async function captureHarnessScreenshot({
|
|
328
333
|
url,
|
|
329
334
|
sessionName,
|
|
330
335
|
screenshotPath,
|
|
331
336
|
focusSelector = null,
|
|
337
|
+
frameContent = false,
|
|
332
338
|
width = 2000,
|
|
333
339
|
height = 1250,
|
|
334
340
|
timeoutMs = 15_000,
|
|
@@ -428,13 +434,50 @@ export async function captureHarnessScreenshot({
|
|
|
428
434
|
|
|
429
435
|
// Let the route settle (async data, resize transitions) before the capture.
|
|
430
436
|
await delay(500);
|
|
437
|
+
|
|
438
|
+
let contentBounds = null;
|
|
439
|
+
if (frameContent && !focusSelector) {
|
|
440
|
+
const measured = await runAgentBrowser(
|
|
441
|
+
['--session', sessionName, '--json', 'eval', MEASURE_SCREENSHOT_CONTENT_SCRIPT],
|
|
442
|
+
{ timeoutMs: 5000 },
|
|
443
|
+
);
|
|
444
|
+
if (measured.exitCode === 0) {
|
|
445
|
+
try {
|
|
446
|
+
const bounds = parseHarnessFromEval(measured.stdout);
|
|
447
|
+
if (bounds?.selector === '[data-notis-screenshot-content]'
|
|
448
|
+
&& Number.isFinite(bounds.width) && bounds.width > 0
|
|
449
|
+
&& Number.isFinite(bounds.height) && bounds.height > 0) {
|
|
450
|
+
contentBounds = bounds;
|
|
451
|
+
}
|
|
452
|
+
} catch { /* Keep the complete viewport if layout measurement is unavailable. */ }
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
let captureFocus = focusSelector || contentBounds?.selector || null;
|
|
456
|
+
if (contentBounds && scaledViewport) {
|
|
457
|
+
const plan = contentCapturePlan(contentBounds, framing || initial, width, height);
|
|
458
|
+
if (await setViewport(sessionName, plan.width, plan.height, plan.scale)) {
|
|
459
|
+
await delay(300);
|
|
460
|
+
if (!plan.focus) {
|
|
461
|
+
const safeHeight = await evalNumber(sessionName, SAFE_VIEWPORT_HEIGHT_SCRIPT, plan.height);
|
|
462
|
+
if (safeHeight < plan.height && await setViewport(sessionName, plan.width, safeHeight, plan.scale)) {
|
|
463
|
+
plan.height = safeHeight;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
captureFocus = plan.focus ? contentBounds.selector : null;
|
|
467
|
+
framing = {
|
|
468
|
+
...(framing || {}), ...plan, content_bounds: contentBounds,
|
|
469
|
+
capture_mode: plan.focus ? 'content' : 'width-fit-viewport',
|
|
470
|
+
};
|
|
471
|
+
await delay(300);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
431
474
|
const designResult = await evalDesignAssertions(sessionName, { viewport: 'capture' });
|
|
432
475
|
const design = Array.isArray(designResult) ? designResult : [];
|
|
433
476
|
|
|
434
477
|
mkdirSync(dirname(screenshotPath), { recursive: true });
|
|
435
478
|
const screenshotArgs = ['--session', sessionName, 'screenshot'];
|
|
436
|
-
if (
|
|
437
|
-
screenshotArgs.push(
|
|
479
|
+
if (captureFocus) {
|
|
480
|
+
screenshotArgs.push(captureFocus);
|
|
438
481
|
}
|
|
439
482
|
screenshotArgs.push(screenshotPath, '--screenshot-format', 'png');
|
|
440
483
|
const shot = await runAgentBrowser(screenshotArgs, { timeoutMs: 15_000 });
|
|
@@ -446,7 +489,13 @@ export async function captureHarnessScreenshot({
|
|
|
446
489
|
// capture came out wrong (e.g. the browser ignored the scale factor),
|
|
447
490
|
// recapture once at a plain fixed viewport.
|
|
448
491
|
const dimensions = pngDimensions(screenshotPath);
|
|
449
|
-
|
|
492
|
+
const expectedDimensions = framing?.capture_mode === 'width-fit-viewport'
|
|
493
|
+
? { width: Math.round(framing.width * framing.scale), height: Math.round(framing.height * framing.scale) }
|
|
494
|
+
: { width, height };
|
|
495
|
+
// Fractional browser scaling can round one pixel either way. Framed output
|
|
496
|
+
// is normalized by the compositor; raw captures still require exact pixels.
|
|
497
|
+
const tolerance = frameContent ? 1 : 0;
|
|
498
|
+
if (!captureFocus && framing && (!dimensions || Math.abs(dimensions.width - expectedDimensions.width) > tolerance || Math.abs(dimensions.height - expectedDimensions.height) > tolerance)) {
|
|
450
499
|
await setViewport(sessionName, width, height);
|
|
451
500
|
await delay(300);
|
|
452
501
|
const retry = await runAgentBrowser(
|
|
@@ -459,10 +508,11 @@ export async function captureHarnessScreenshot({
|
|
|
459
508
|
framing = null;
|
|
460
509
|
}
|
|
461
510
|
|
|
462
|
-
if (
|
|
511
|
+
if (captureFocus) {
|
|
463
512
|
framing = {
|
|
464
513
|
...(framing || {}),
|
|
465
|
-
focus_selector:
|
|
514
|
+
focus_selector: captureFocus,
|
|
515
|
+
content_bounds: contentBounds,
|
|
466
516
|
source_dimensions: dimensions,
|
|
467
517
|
};
|
|
468
518
|
}
|
|
@@ -286,13 +286,14 @@ export function detectProjectWarnings(projectDir, appConfig = null) {
|
|
|
286
286
|
appConfig?.capabilities?.workspaceDatabases === 'read';
|
|
287
287
|
if (
|
|
288
288
|
appConfig
|
|
289
|
+
&& appConfig.kind !== 'report'
|
|
289
290
|
&& !readsWorkspaceDatabases
|
|
290
291
|
&& (!Array.isArray(appConfig.databases) || appConfig.databases.length === 0)
|
|
291
292
|
) {
|
|
292
293
|
warnings.push('No database references declared in notis.config.ts.');
|
|
293
294
|
}
|
|
294
295
|
|
|
295
|
-
if (appConfig) {
|
|
296
|
+
if (appConfig && appConfig.kind !== 'report') {
|
|
296
297
|
try {
|
|
297
298
|
const listing = inspectListingReadiness(projectDir, appConfig);
|
|
298
299
|
warnings.push(...listing.errors, ...listing.warnings);
|
|
@@ -813,6 +814,29 @@ export function generateManifest(appConfig, projectDir) {
|
|
|
813
814
|
return entry;
|
|
814
815
|
});
|
|
815
816
|
|
|
817
|
+
if (appConfig.kind === 'report') {
|
|
818
|
+
if (routes.length !== 1 || routes[0].collection || routes[0].resourceDeepLinks) {
|
|
819
|
+
throw usageError('Reports require one standalone route, without app collections or resource deep links.');
|
|
820
|
+
}
|
|
821
|
+
if (['databases', 'skills', 'automations', 'onboarding', 'tagline', 'categories', 'author', 'screenshots'].some(key => {
|
|
822
|
+
const value = appConfig[key];
|
|
823
|
+
return Array.isArray(value) ? value.length > 0 : Boolean(value);
|
|
824
|
+
})) throw usageError('Reports cannot own databases, skills, automations or Store metadata.');
|
|
825
|
+
if (Object.values(appConfig.capabilities || {}).some(value => value !== 'read')) {
|
|
826
|
+
throw usageError('Reports support read capabilities only; shell grants require an installed app.');
|
|
827
|
+
}
|
|
828
|
+
const databaseAccess = appConfig.databaseAccess || [];
|
|
829
|
+
if (!Array.isArray(databaseAccess) || databaseAccess.some(entry =>
|
|
830
|
+
!entry || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(entry.id || '') ||
|
|
831
|
+
!['read', 'write'].includes(entry.access)
|
|
832
|
+
)) throw usageError('databaseAccess entries require an existing database UUID and read/write access.');
|
|
833
|
+
return { kind: 'report', version: 1, spec_version: 4,
|
|
834
|
+
routes: routes.map(route => ({ ...route, default: true, tool_access: { base: { tools: appConfig.tools || [] } } })),
|
|
835
|
+
bundle: { js: 'bundle/app.js', css: 'bundle/app.css' },
|
|
836
|
+
tools: appConfig.tools || [], tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
|
|
837
|
+
capabilities: normalizeAppCapabilities(appConfig.capabilities), database_access: databaseAccess };
|
|
838
|
+
}
|
|
839
|
+
|
|
816
840
|
// Database entries may be a bare slug (structure only) or an object opting
|
|
817
841
|
// into shipping rows to installers. Normalize to the manifest's snake_case.
|
|
818
842
|
const databases = (appConfig.databases || []).map((entry) => {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Runs only in the capture harness, never in an installed app. */
|
|
2
|
+
export function measureScreenshotContent() {
|
|
3
|
+
const root = document.getElementById('root');
|
|
4
|
+
if (!root) return null;
|
|
5
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
6
|
+
const visible = (element) => {
|
|
7
|
+
const rect = element.getBoundingClientRect();
|
|
8
|
+
const style = getComputedStyle(element);
|
|
9
|
+
return rect.width > 0 && rect.height > 0
|
|
10
|
+
&& style.display !== 'none' && style.visibility !== 'hidden';
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Follow layout wrappers only while they contain the entire visible app.
|
|
14
|
+
// Never select one column, card, or control out of a multi-pane layout.
|
|
15
|
+
let node = root;
|
|
16
|
+
let content = null;
|
|
17
|
+
while (node) {
|
|
18
|
+
const hasOwnText = Array.from(node.childNodes).some(
|
|
19
|
+
(child) => child.nodeType === 3 && child.textContent.trim(),
|
|
20
|
+
);
|
|
21
|
+
const children = Array.from(node.children).filter(visible);
|
|
22
|
+
if (hasOwnText || children.length !== 1) break;
|
|
23
|
+
node = children[0];
|
|
24
|
+
const rect = node.getBoundingClientRect();
|
|
25
|
+
if (['DIV', 'MAIN', 'SECTION', 'ARTICLE', 'FORM'].includes(node.tagName)
|
|
26
|
+
&& rect.width >= 320 && rect.height >= 160
|
|
27
|
+
&& rect.width < viewportWidth * 0.9) {
|
|
28
|
+
content = node;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!content) return null;
|
|
32
|
+
content.setAttribute('data-notis-screenshot-content', '');
|
|
33
|
+
const rect = content.getBoundingClientRect();
|
|
34
|
+
return {
|
|
35
|
+
selector: '[data-notis-screenshot-content]',
|
|
36
|
+
width: rect.width,
|
|
37
|
+
height: rect.height,
|
|
38
|
+
viewport_width: viewportWidth,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const MEASURE_SCREENSHOT_CONTENT_SCRIPT = `(${measureScreenshotContent.toString()})()`;
|
|
43
|
+
|
|
44
|
+
/** Keep long pages readable, and rasterize bounded views at their final width. */
|
|
45
|
+
export function contentCapturePlan(bounds, viewport, outputWidth, outputHeight) {
|
|
46
|
+
const aspect = outputWidth / outputHeight;
|
|
47
|
+
if (bounds.width / bounds.height < aspect * 0.75) {
|
|
48
|
+
const width = Math.max(768, Math.ceil((bounds.width + 32) / 8) * 8);
|
|
49
|
+
return { focus: false, width, height: Math.round(width / aspect), scale: outputWidth / width };
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
focus: true,
|
|
53
|
+
width: viewport.width,
|
|
54
|
+
height: viewport.height,
|
|
55
|
+
scale: Math.min(4, outputWidth / bounds.width),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function safeViewportHeight(rectangles, height) {
|
|
60
|
+
const crossing = rectangles.filter((rect) => rect.top < height && rect.bottom > height
|
|
61
|
+
&& rect.top >= height * 0.85);
|
|
62
|
+
return crossing.length
|
|
63
|
+
? Math.max(Math.ceil(height * 0.85), Math.floor(Math.min(...crossing.map((rect) => rect.top)) - 8))
|
|
64
|
+
: height;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// End a long-page viewport in whitespace rather than through a text line or
|
|
68
|
+
// control. Only geometry leaves the browser; no page text is returned.
|
|
69
|
+
export const SAFE_VIEWPORT_HEIGHT_SCRIPT = `(() => {
|
|
70
|
+
const root = document.getElementById('root');
|
|
71
|
+
if (!root) return innerHeight;
|
|
72
|
+
const rects = [];
|
|
73
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
74
|
+
let node;
|
|
75
|
+
while ((node = walker.nextNode())) {
|
|
76
|
+
if (!node.textContent.trim() || getComputedStyle(node.parentElement).visibility !== 'visible') continue;
|
|
77
|
+
const range = document.createRange(); range.selectNodeContents(node);
|
|
78
|
+
for (const rect of range.getClientRects()) rects.push({ top: rect.top, bottom: rect.bottom });
|
|
79
|
+
}
|
|
80
|
+
for (const control of root.querySelectorAll('button,input,select,textarea')) {
|
|
81
|
+
const rect = control.getBoundingClientRect();
|
|
82
|
+
rects.push({ top: rect.top, bottom: rect.bottom });
|
|
83
|
+
}
|
|
84
|
+
return (${safeViewportHeight.toString()})(rects, innerHeight);
|
|
85
|
+
})()`;
|
|
@@ -38,15 +38,17 @@ export function resolveStoreScreenshotAccent(accent, seed = 'notis-app') {
|
|
|
38
38
|
return ACCENT_NAMES[stableHash(seed) % ACCENT_NAMES.length];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function storeScreenshotLayout(width = 2000, height = 1250) {
|
|
41
|
+
export function storeScreenshotLayout(width = 2000, height = 1250, contentAspect = STORE_SCREENSHOT_ASPECT) {
|
|
42
|
+
const aspect = Number.isFinite(contentAspect) && contentAspect > 0
|
|
43
|
+
? contentAspect : STORE_SCREENSHOT_ASPECT;
|
|
42
44
|
const horizontalInset = Math.max(48, Math.round(width * 0.06));
|
|
43
45
|
const verticalInset = Math.max(40, Math.round(height * 0.06));
|
|
44
46
|
let cardWidth = width - horizontalInset * 2;
|
|
45
|
-
let cardHeight = Math.round(cardWidth /
|
|
47
|
+
let cardHeight = Math.round(cardWidth / aspect);
|
|
46
48
|
const maximumCardHeight = height - verticalInset * 2;
|
|
47
49
|
if (cardHeight > maximumCardHeight) {
|
|
48
50
|
cardHeight = maximumCardHeight;
|
|
49
|
-
cardWidth = Math.round(cardHeight *
|
|
51
|
+
cardWidth = Math.round(cardHeight * aspect);
|
|
50
52
|
}
|
|
51
53
|
const cardLeft = Math.round((width - cardWidth) / 2);
|
|
52
54
|
const cardTop = Math.round((height - cardHeight) / 2) - Math.round(height * 0.008);
|
|
@@ -80,7 +82,7 @@ function roundedRectSvg(width, height, radius, { fill = '#ffffff', stroke = null
|
|
|
80
82
|
|
|
81
83
|
/**
|
|
82
84
|
* Composite a truthful browser capture into the deterministic Store frame.
|
|
83
|
-
*
|
|
85
|
+
* Fit the complete source pixels; never crop controls or redraw the app UI.
|
|
84
86
|
*/
|
|
85
87
|
export async function composeStoreScreenshot({
|
|
86
88
|
inputPath,
|
|
@@ -94,13 +96,14 @@ export async function composeStoreScreenshot({
|
|
|
94
96
|
}) {
|
|
95
97
|
const sharp = await loadSharp();
|
|
96
98
|
const resolvedTheme = theme === 'dark' ? 'dark' : 'light';
|
|
97
|
-
const
|
|
99
|
+
const source = await sharp(inputPath).metadata();
|
|
100
|
+
const layout = storeScreenshotLayout(width, height, source.width / source.height);
|
|
98
101
|
const resolvedAccent = resolveStoreScreenshotAccent(accent, seed);
|
|
99
102
|
|
|
100
103
|
const roundedCapture = await sharp(inputPath)
|
|
101
104
|
.resize(layout.cardWidth, layout.cardHeight, {
|
|
102
|
-
fit: '
|
|
103
|
-
|
|
105
|
+
fit: 'contain',
|
|
106
|
+
background: resolvedTheme === 'dark' ? '#09090b' : '#ffffff',
|
|
104
107
|
})
|
|
105
108
|
.composite([{
|
|
106
109
|
input: roundedRectSvg(layout.cardWidth, layout.cardHeight, layout.radius),
|
|
@@ -207,8 +207,8 @@
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
async function runtimeQuery(body) {
|
|
210
|
-
if (!apiBase || !jwt || !descriptor.
|
|
211
|
-
throw new Error('live harness mode requires apiBase, jwt, and
|
|
210
|
+
if (!apiBase || !jwt || !(descriptor.resource?.kind === 'report' ? descriptor.resource.id : descriptor.app?.id)) {
|
|
211
|
+
throw new Error('live harness mode requires apiBase, jwt, and runtime resource identity');
|
|
212
212
|
}
|
|
213
213
|
const response = await fetch(`${String(apiBase).replace(/\/$/, '')}/portal_views/runtime_query`, {
|
|
214
214
|
method: 'POST',
|
|
@@ -217,7 +217,9 @@
|
|
|
217
217
|
Authorization: `Bearer ${jwt}`,
|
|
218
218
|
},
|
|
219
219
|
body: JSON.stringify({
|
|
220
|
-
|
|
220
|
+
...(descriptor.resource?.kind === 'report'
|
|
221
|
+
? { document_id: descriptor.resource.id, revision: descriptor.resource.revision }
|
|
222
|
+
: { app_id: descriptor.app.id }),
|
|
221
223
|
route_slug: descriptor.route && descriptor.route.slug,
|
|
222
224
|
...body,
|
|
223
225
|
}),
|
|
@@ -230,7 +232,10 @@
|
|
|
230
232
|
if (!payload) {
|
|
231
233
|
throw new Error(snippet ? `Runtime response was not JSON: ${snippet}` : 'Runtime response was empty');
|
|
232
234
|
}
|
|
233
|
-
if (payload.status === 'error' || payload.error
|
|
235
|
+
if (payload.status === 'error' || payload.error || (descriptor.resource?.kind === 'report' && (
|
|
236
|
+
['error', 'failed', 'failure'].includes(String(payload.status || '').trim().toLowerCase())
|
|
237
|
+
|| payload.ok === false || payload.success === false || payload.successful === false
|
|
238
|
+
))) {
|
|
234
239
|
throw new Error(payload.message || payload.error || 'Runtime request failed');
|
|
235
240
|
}
|
|
236
241
|
return payload;
|
|
@@ -238,6 +243,7 @@
|
|
|
238
243
|
|
|
239
244
|
function stubRuntime() {
|
|
240
245
|
return {
|
|
246
|
+
resource: descriptor.resource,
|
|
241
247
|
app: descriptor.app,
|
|
242
248
|
route: descriptor.route,
|
|
243
249
|
context: descriptor.context || {},
|
|
@@ -287,6 +293,7 @@
|
|
|
287
293
|
request: async (path, options) => tracked(
|
|
288
294
|
record('request', { path, options }),
|
|
289
295
|
async () => {
|
|
296
|
+
if (descriptor.resource?.kind === 'report') throw new Error('Reports access data through declared tools.');
|
|
290
297
|
if (fixtures && fixtures.requests && Object.prototype.hasOwnProperty.call(fixtures.requests, path)) {
|
|
291
298
|
return structuredClone(fixtures.requests[path]);
|
|
292
299
|
}
|
|
@@ -349,6 +356,7 @@
|
|
|
349
356
|
request: async (path, options) => tracked(
|
|
350
357
|
record('request', { path, options }),
|
|
351
358
|
async () => {
|
|
359
|
+
if (descriptor.resource?.kind === 'report') throw new Error('Reports access data through declared tools.');
|
|
352
360
|
const response = await fetch(`${String(apiBase).replace(/\/$/, '')}${path}`, {
|
|
353
361
|
method: (options && options.method) || 'GET',
|
|
354
362
|
headers: {
|