@legionworks/facet 1.10.1 → 1.10.2
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/dist/gallery/frame/chunks/{markdown-zazx9h4n.js → markdown-31ee7zgx.js} +1 -1
- package/dist/gallery/frame/chunks/{markdown-g3asgaw3.js → markdown-7scxp44j.js} +1 -1
- package/dist/gallery/frame/chunks/{markdown-z1wc0v0d.js → markdown-mksxbxx8.js} +2 -2
- package/dist/gallery/frame/chunks/{markdown-d1wfbs70.js → markdown-n9xcxy80.js} +14 -9
- package/dist/gallery/frame/chunks/{markdown-rd8w0ng9.js → markdown-rkt612xp.js} +1 -1
- package/dist/gallery/frame/chunks/{mermaid-de7p4v0x.js → mermaid-b8fxhsgg.js} +3 -3
- package/dist/gallery/frame/runtime/chart.js +3 -3
- package/dist/gallery/frame/runtime/html.js +3 -3
- package/dist/gallery/frame/runtime/markdown.js +8 -4
- package/dist/gallery/frame/runtime/mermaid.js +4 -4
- package/dist/gallery/frame/runtime/svg.js +3 -3
- package/dist/gallery/frame/runtime/tsx.js +3 -3
- package/docs/reference/validation.md +10 -2
- package/package.json +1 -1
- package/src/gallery-web/frame/renderers/markdown.ts +5 -1
- package/src/gallery-web/frame/renderers/registry.ts +17 -10
- package/src/shared/html/policy.ts +9 -0
- package/src/validation/tier0/html.ts +6 -9
- package/src/validation/tier0/markdown.ts +2 -12
- package/src/validation/tier1/isolated-probe.ts +20 -11
- package/src/validation/tier1/limits.ts +11 -6
- package/src/validation/tier1/protocol-probe.ts +60 -26
- package/src/validation/tier1/runner.ts +173 -10
- package/src/validation/tier1/verdict.ts +9 -0
|
@@ -10,11 +10,11 @@ import {
|
|
|
10
10
|
} from "./markdown-h7pzv34e.js";
|
|
11
11
|
import {
|
|
12
12
|
importSanitizedSvgText
|
|
13
|
-
} from "./markdown-
|
|
13
|
+
} from "./markdown-31ee7zgx.js";
|
|
14
14
|
import {
|
|
15
15
|
FacetRenderError,
|
|
16
16
|
decodeArtifactBytes
|
|
17
|
-
} from "./markdown-
|
|
17
|
+
} from "./markdown-n9xcxy80.js";
|
|
18
18
|
import {
|
|
19
19
|
dedent
|
|
20
20
|
} from "./markdown-qx3cngz2.js";
|
|
@@ -51,6 +51,15 @@ function isHtmlEventHandlerAttribute(name) {
|
|
|
51
51
|
function isHtmlInlineStyleAttribute(name) {
|
|
52
52
|
return name.toLowerCase() === "style";
|
|
53
53
|
}
|
|
54
|
+
function isExternalHttpsImageSource(src) {
|
|
55
|
+
if (typeof src !== "string")
|
|
56
|
+
return false;
|
|
57
|
+
try {
|
|
58
|
+
return new URL(src).protocol === "https:";
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
54
63
|
function isAllowedHtmlUrl(elementName, attributeName, value) {
|
|
55
64
|
const element = htmlUrlBearingElement(elementName);
|
|
56
65
|
if (element === null || !isHtmlUrlBearingAttribute(element, attributeName))
|
|
@@ -177,7 +186,8 @@ function countPageShim() {
|
|
|
177
186
|
const graphRoots = roots.filter((root) => root.getAttribute(RENDERER_GRAPH_ATTRIBUTE) === "true");
|
|
178
187
|
const allMarkedCandidates = safeSelectorElements(MARKED_ROOT_SELECTOR);
|
|
179
188
|
const markedSet = new Set(allMarkedCandidates);
|
|
180
|
-
const
|
|
189
|
+
const contentRoots = allMarkedCandidates.filter((root) => root.nodeName.toLowerCase() !== "svg" && !hasMarkedRootAncestor(root, markedSet));
|
|
190
|
+
const htmlRoots = contentRoots.filter((root) => root.getAttribute("data-facet-renderer-kind") !== "markdown");
|
|
181
191
|
const html = htmlRoots.length === 0 ? undefined : {
|
|
182
192
|
rendererRootCount: htmlRoots.length,
|
|
183
193
|
headingCount: 0,
|
|
@@ -193,24 +203,19 @@ function countPageShim() {
|
|
|
193
203
|
html.listCount += safeSelectorElementsWithin(root, HTML_STRUCTURAL_GROUPS.lists.join(",")).length;
|
|
194
204
|
const images = safeSelectorElementsWithin(root, HTML_STRUCTURAL_GROUPS.images.join(","));
|
|
195
205
|
html.imageCount += images.length;
|
|
196
|
-
html.externalImageCount += images.filter((image) =>
|
|
197
|
-
try {
|
|
198
|
-
return new URL(image.getAttribute("src") ?? "").protocol === "https:";
|
|
199
|
-
} catch {
|
|
200
|
-
return false;
|
|
201
|
-
}
|
|
202
|
-
}).length;
|
|
206
|
+
html.externalImageCount += images.filter((image) => isExternalHttpsImageSource(image.getAttribute("src"))).length;
|
|
203
207
|
html.canvasCount += safeSelectorElementsWithin(root, HTML_STRUCTURAL_GROUPS.canvases.join(",")).length;
|
|
204
208
|
}
|
|
205
209
|
const mermaidNodeCount = graphRoots.reduce((count, root) => count + safeSelectorElementsWithin(root, "g.node").length, 0);
|
|
206
210
|
const opaqueRegionCount = safeSelectorElements("*").filter((element) => element.nodeName.toLowerCase() === "canvas").length;
|
|
211
|
+
const externalImageCount = contentRoots.reduce((count, root) => count + safeSelectorElementsWithin(root, HTML_STRUCTURAL_GROUPS.images.join(",")).filter((image) => isExternalHttpsImageSource(image.getAttribute("src"))).length, 0);
|
|
207
212
|
return {
|
|
208
213
|
rendererRootSvgCount: roots.length,
|
|
209
214
|
graphCount: graphRoots.length,
|
|
210
215
|
mermaidNodeCount,
|
|
211
216
|
visibleSvgCount: roots.filter(nonDegenerateViewBox).length,
|
|
212
217
|
opaqueRegionCount,
|
|
213
|
-
externalImageCount
|
|
218
|
+
externalImageCount,
|
|
214
219
|
errorCount: safeSelectorElements("[data-facet-error]").length,
|
|
215
220
|
...html === undefined ? {} : { html }
|
|
216
221
|
};
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
isHtmlInlineStyleAttribute,
|
|
7
7
|
isHtmlUrlAttributeName,
|
|
8
8
|
isHtmlUrlBearingAttribute
|
|
9
|
-
} from "./markdown-
|
|
9
|
+
} from "./markdown-n9xcxy80.js";
|
|
10
10
|
|
|
11
11
|
// src/gallery-web/frame/renderers/html.ts
|
|
12
12
|
function shouldKeepAttribute(elementName, name, value) {
|
|
@@ -2,12 +2,12 @@ import {
|
|
|
2
2
|
mermaidInitializeConfig,
|
|
3
3
|
renderMermaidDocument,
|
|
4
4
|
renderMermaidInto
|
|
5
|
-
} from "./markdown-
|
|
5
|
+
} from "./markdown-mksxbxx8.js";
|
|
6
6
|
import"./markdown-6zz8efx2.js";
|
|
7
7
|
import"./markdown-rm2yxfjj.js";
|
|
8
8
|
import"./markdown-h7pzv34e.js";
|
|
9
|
-
import"./markdown-
|
|
10
|
-
import"./markdown-
|
|
9
|
+
import"./markdown-31ee7zgx.js";
|
|
10
|
+
import"./markdown-n9xcxy80.js";
|
|
11
11
|
import"./markdown-s1jatrnk.js";
|
|
12
12
|
import"./markdown-m7frq8x8.js";
|
|
13
13
|
import"./markdown-tvdpppw8.js";
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
importSanitizedSvgText
|
|
3
|
-
} from "../chunks/markdown-
|
|
3
|
+
} from "../chunks/markdown-31ee7zgx.js";
|
|
4
4
|
import {
|
|
5
5
|
installGalleryFrameApi
|
|
6
|
-
} from "../chunks/markdown-
|
|
6
|
+
} from "../chunks/markdown-7scxp44j.js";
|
|
7
7
|
import {
|
|
8
8
|
FacetRenderError,
|
|
9
9
|
createRendererRegistry,
|
|
10
10
|
decodeArtifactBytes
|
|
11
|
-
} from "../chunks/markdown-
|
|
11
|
+
} from "../chunks/markdown-n9xcxy80.js";
|
|
12
12
|
import {
|
|
13
13
|
Accent_default,
|
|
14
14
|
Dark2_default,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
renderHtml
|
|
3
|
-
} from "../chunks/markdown-
|
|
3
|
+
} from "../chunks/markdown-rkt612xp.js";
|
|
4
4
|
import {
|
|
5
5
|
installGalleryFrameApi
|
|
6
|
-
} from "../chunks/markdown-
|
|
6
|
+
} from "../chunks/markdown-7scxp44j.js";
|
|
7
7
|
import {
|
|
8
8
|
createRendererRegistry
|
|
9
|
-
} from "../chunks/markdown-
|
|
9
|
+
} from "../chunks/markdown-n9xcxy80.js";
|
|
10
10
|
import"../chunks/markdown-809k7q41.js";
|
|
11
11
|
|
|
12
12
|
// src/gallery-web/frame/entries/html.ts
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
installGalleryFrameApi
|
|
3
|
-
} from "../chunks/markdown-
|
|
3
|
+
} from "../chunks/markdown-7scxp44j.js";
|
|
4
4
|
import {
|
|
5
5
|
createRendererRegistry,
|
|
6
6
|
decodeArtifactBytes
|
|
7
|
-
} from "../chunks/markdown-
|
|
7
|
+
} from "../chunks/markdown-n9xcxy80.js";
|
|
8
8
|
import {
|
|
9
9
|
__require
|
|
10
10
|
} from "../chunks/markdown-809k7q41.js";
|
|
@@ -1380,7 +1380,7 @@ async function renderMarkdown(ctx, bytes) {
|
|
|
1380
1380
|
const template = document.createElement("template");
|
|
1381
1381
|
template.innerHTML = html;
|
|
1382
1382
|
const mermaidBlocks = Array.from(template.content.querySelectorAll("pre > code.language-mermaid"));
|
|
1383
|
-
const renderMermaidInto = mermaidBlocks.length === 0 ? null : (await import("../chunks/mermaid-
|
|
1383
|
+
const renderMermaidInto = mermaidBlocks.length === 0 ? null : (await import("../chunks/mermaid-b8fxhsgg.js")).renderMermaidInto;
|
|
1384
1384
|
for (const code of mermaidBlocks) {
|
|
1385
1385
|
const source = code.textContent ?? "";
|
|
1386
1386
|
const pre = code.parentElement;
|
|
@@ -1395,7 +1395,11 @@ async function renderMarkdown(ctx, bytes) {
|
|
|
1395
1395
|
else
|
|
1396
1396
|
pre.remove();
|
|
1397
1397
|
}
|
|
1398
|
-
|
|
1398
|
+
const root = document.createElement("div");
|
|
1399
|
+
root.setAttribute("data-facet-renderer-root", "true");
|
|
1400
|
+
root.setAttribute("data-facet-renderer-kind", "markdown");
|
|
1401
|
+
root.appendChild(template.content);
|
|
1402
|
+
ctx.container.appendChild(root);
|
|
1399
1403
|
}
|
|
1400
1404
|
|
|
1401
1405
|
// src/gallery-web/frame/entries/markdown.ts
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
renderMermaidDocument
|
|
3
|
-
} from "../chunks/markdown-
|
|
3
|
+
} from "../chunks/markdown-mksxbxx8.js";
|
|
4
4
|
import"../chunks/markdown-6zz8efx2.js";
|
|
5
5
|
import"../chunks/markdown-rm2yxfjj.js";
|
|
6
6
|
import"../chunks/markdown-h7pzv34e.js";
|
|
7
|
-
import"../chunks/markdown-
|
|
7
|
+
import"../chunks/markdown-31ee7zgx.js";
|
|
8
8
|
import {
|
|
9
9
|
installGalleryFrameApi
|
|
10
|
-
} from "../chunks/markdown-
|
|
10
|
+
} from "../chunks/markdown-7scxp44j.js";
|
|
11
11
|
import {
|
|
12
12
|
createRendererRegistry
|
|
13
|
-
} from "../chunks/markdown-
|
|
13
|
+
} from "../chunks/markdown-n9xcxy80.js";
|
|
14
14
|
import"../chunks/markdown-s1jatrnk.js";
|
|
15
15
|
import"../chunks/markdown-m7frq8x8.js";
|
|
16
16
|
import"../chunks/markdown-tvdpppw8.js";
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
renderSvgDocument
|
|
3
|
-
} from "../chunks/markdown-
|
|
3
|
+
} from "../chunks/markdown-31ee7zgx.js";
|
|
4
4
|
import {
|
|
5
5
|
installGalleryFrameApi
|
|
6
|
-
} from "../chunks/markdown-
|
|
6
|
+
} from "../chunks/markdown-7scxp44j.js";
|
|
7
7
|
import {
|
|
8
8
|
createRendererRegistry
|
|
9
|
-
} from "../chunks/markdown-
|
|
9
|
+
} from "../chunks/markdown-n9xcxy80.js";
|
|
10
10
|
import"../chunks/markdown-809k7q41.js";
|
|
11
11
|
|
|
12
12
|
// src/gallery-web/frame/entries/svg.ts
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
renderHtml
|
|
3
|
-
} from "../chunks/markdown-
|
|
3
|
+
} from "../chunks/markdown-rkt612xp.js";
|
|
4
4
|
import {
|
|
5
5
|
installGalleryFrameApi
|
|
6
|
-
} from "../chunks/markdown-
|
|
6
|
+
} from "../chunks/markdown-7scxp44j.js";
|
|
7
7
|
import {
|
|
8
8
|
RENDER_ERROR_ATTRIBUTE,
|
|
9
9
|
appendRenderError,
|
|
10
10
|
createRendererRegistry
|
|
11
|
-
} from "../chunks/markdown-
|
|
11
|
+
} from "../chunks/markdown-n9xcxy80.js";
|
|
12
12
|
import"../chunks/markdown-809k7q41.js";
|
|
13
13
|
|
|
14
14
|
// src/gallery-web/frame/renderers/tsx.ts
|
|
@@ -14,7 +14,7 @@ Every other layer is bound to it through `VerdictSchema.status`.
|
|
|
14
14
|
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
15
15
|
| `ok` | Counts agree across protocol + shim + isolated worlds, layout observable, no discriminative errors. |
|
|
16
16
|
| `error` | Counts disagree with the lexical expectation OR the protocol surfaced a discriminative error. |
|
|
17
|
-
| `partial:layout_unverified` | Counts agree but the layout pass is unverified (no SVG rendered with a non-degenerate viewBox). MUST carry a screenshot path on the wire.
|
|
17
|
+
| `partial:layout_unverified` | Counts agree but the layout pass is unverified (no SVG rendered with a non-degenerate viewBox). Markdown with zero expected renderer roots and opaque regions instead reaches `ok` when protocol and isolated observations agree that its renderer root is non-empty. Empty Markdown remains unverified. MUST carry a screenshot path on the wire. |
|
|
18
18
|
| `partial:opaque_content` | An opaque DOM region was observed, so structural contents were not verified. MUST carry a screenshot path, or a typed `screenshotError` marker when capture fails transiently. |
|
|
19
19
|
| `partial:external_resources` | The artifact references external HTTPS images the no-egress verifier could not observe. MUST carry a screenshot path, or a typed `screenshotError` marker when capture fails transiently. |
|
|
20
20
|
| `partial:unstable` | TSX interactive mode: the structure observed at the render barrier differed from the structure observed after a bounded stability window. Deliberately NOT `tampered` — a legitimately animated or async-loading component also changes structure between observations, and branding that a forgery would manufacture the false-verdict class this project has spent three arcs eliminating. `tampered` stays reserved for channel divergence. MUST carry a screenshot path, or a typed `screenshotError` marker when capture fails transiently. |
|
|
@@ -48,7 +48,7 @@ ordering, top to bottom:
|
|
|
48
48
|
7. `error` (declared-opaque, observed-zero) — the artifact declared opaque regions and none were seen.
|
|
49
49
|
8. `partial:opaque_content` — single-snapshot claim: structure has opaque regions.
|
|
50
50
|
9. `partial:external_resources` — single-snapshot claim: structure has external HTTP references.
|
|
51
|
-
10. `partial:layout_unverified` — single-snapshot claim: visible SVG with zeroed viewBoxes.
|
|
51
|
+
10. `partial:layout_unverified` — single-snapshot claim: visible SVG with zeroed viewBoxes; it also applies to empty Markdown, while diagram-free Markdown with matching non-empty observations has no layout to verify.
|
|
52
52
|
11. `ok`.
|
|
53
53
|
|
|
54
54
|
The rule generalizing rows 4 and 6: a claim that depends on ONE observation
|
|
@@ -77,6 +77,14 @@ WebP; legacy PNG evidence remains readable and exportable. Before static
|
|
|
77
77
|
capture it emulates `prefers-reduced-motion: reduce` and awaits
|
|
78
78
|
`document.fonts.ready`; these pre-flights keep repeated captures byte-identical.
|
|
79
79
|
|
|
80
|
+
If resizing the capture viewport makes the artifact grow, Tier 1 restores the
|
|
81
|
+
1280×800 evidence viewport and captures the artifact's scroll area in tiles.
|
|
82
|
+
The tiles form one still WebP image, including for interactive or animated
|
|
83
|
+
artifacts. This avoids changing viewport-sized elements such as `100vh` while
|
|
84
|
+
retaining content below and to the right. Fixed and sticky elements repeat in
|
|
85
|
+
each tile. Content that grows while the tiles are captured gets
|
|
86
|
+
`screenshot_unavailable` instead of an incomplete image.
|
|
87
|
+
|
|
80
88
|
Tier 1 uses the same artifact-type layout rules as the gallery frame: Mermaid,
|
|
81
89
|
SVG, and chart roots are safely centered on both axes, with oversized content
|
|
82
90
|
remaining reachable by scrolling; Markdown is top-aligned in a horizontally
|
package/package.json
CHANGED
|
@@ -77,5 +77,9 @@ export async function renderMarkdown(ctx: RenderContext, bytes: Uint8Array): Pro
|
|
|
77
77
|
if (region.firstElementChild !== null) pre.replaceWith(region);
|
|
78
78
|
else pre.remove();
|
|
79
79
|
}
|
|
80
|
-
|
|
80
|
+
const root = document.createElement("div");
|
|
81
|
+
root.setAttribute("data-facet-renderer-root", "true");
|
|
82
|
+
root.setAttribute("data-facet-renderer-kind", "markdown");
|
|
83
|
+
root.appendChild(template.content);
|
|
84
|
+
ctx.container.appendChild(root);
|
|
81
85
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ArtifactType } from "../../../shared/contracts/artifact-types";
|
|
2
2
|
import { isRenderer, type Renderer as RendererKind } from "../../../shared/contracts/renderers";
|
|
3
|
-
import { HTML_STRUCTURAL_GROUPS } from "../../../shared/html/policy";
|
|
3
|
+
import { HTML_STRUCTURAL_GROUPS, isExternalHttpsImageSource } from "../../../shared/html/policy";
|
|
4
4
|
import type { VerdictObserved } from "../../../shared/contracts/validation";
|
|
5
5
|
import type { ObservedCountKey } from "../../../shared/contracts/observed-counts";
|
|
6
6
|
import { isTsxExecutionMode, type TsxExecutionMode } from "../../../shared/tsx/execution";
|
|
@@ -140,9 +140,12 @@ export function countPageShim(): PageShimCounts {
|
|
|
140
140
|
const graphRoots = roots.filter((root) => root.getAttribute(RENDERER_GRAPH_ATTRIBUTE) === "true");
|
|
141
141
|
const allMarkedCandidates = safeSelectorElements(MARKED_ROOT_SELECTOR);
|
|
142
142
|
const markedSet = new Set(allMarkedCandidates);
|
|
143
|
-
const
|
|
143
|
+
const contentRoots = allMarkedCandidates.filter(
|
|
144
144
|
(root) => root.nodeName.toLowerCase() !== "svg" && !hasMarkedRootAncestor(root, markedSet),
|
|
145
145
|
);
|
|
146
|
+
const htmlRoots = contentRoots.filter(
|
|
147
|
+
(root) => root.getAttribute("data-facet-renderer-kind") !== "markdown",
|
|
148
|
+
);
|
|
146
149
|
const html =
|
|
147
150
|
htmlRoots.length === 0
|
|
148
151
|
? undefined
|
|
@@ -170,13 +173,9 @@ export function countPageShim(): PageShimCounts {
|
|
|
170
173
|
).length;
|
|
171
174
|
const images = safeSelectorElementsWithin(root, HTML_STRUCTURAL_GROUPS.images.join(","));
|
|
172
175
|
html!.imageCount += images.length;
|
|
173
|
-
html!.externalImageCount += images.filter((image) =>
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
} catch {
|
|
177
|
-
return false;
|
|
178
|
-
}
|
|
179
|
-
}).length;
|
|
176
|
+
html!.externalImageCount += images.filter((image) =>
|
|
177
|
+
isExternalHttpsImageSource(image.getAttribute("src")),
|
|
178
|
+
).length;
|
|
180
179
|
html!.canvasCount += safeSelectorElementsWithin(
|
|
181
180
|
root,
|
|
182
181
|
HTML_STRUCTURAL_GROUPS.canvases.join(","),
|
|
@@ -192,13 +191,21 @@ export function countPageShim(): PageShimCounts {
|
|
|
192
191
|
const opaqueRegionCount = safeSelectorElements("*").filter(
|
|
193
192
|
(element) => element.nodeName.toLowerCase() === "canvas",
|
|
194
193
|
).length;
|
|
194
|
+
const externalImageCount = contentRoots.reduce(
|
|
195
|
+
(count, root) =>
|
|
196
|
+
count +
|
|
197
|
+
safeSelectorElementsWithin(root, HTML_STRUCTURAL_GROUPS.images.join(",")).filter((image) =>
|
|
198
|
+
isExternalHttpsImageSource(image.getAttribute("src")),
|
|
199
|
+
).length,
|
|
200
|
+
0,
|
|
201
|
+
);
|
|
195
202
|
return {
|
|
196
203
|
rendererRootSvgCount: roots.length,
|
|
197
204
|
graphCount: graphRoots.length,
|
|
198
205
|
mermaidNodeCount,
|
|
199
206
|
visibleSvgCount: roots.filter(nonDegenerateViewBox).length,
|
|
200
207
|
opaqueRegionCount,
|
|
201
|
-
externalImageCount
|
|
208
|
+
externalImageCount,
|
|
202
209
|
errorCount: safeSelectorElements("[data-facet-error]").length,
|
|
203
210
|
...(html === undefined ? {} : { html }),
|
|
204
211
|
};
|
|
@@ -61,6 +61,15 @@ export function isHtmlInlineStyleAttribute(name: string): boolean {
|
|
|
61
61
|
return name.toLowerCase() === "style";
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
export function isExternalHttpsImageSource(src: string | null | undefined): boolean {
|
|
65
|
+
if (typeof src !== "string") return false;
|
|
66
|
+
try {
|
|
67
|
+
return new URL(src).protocol === "https:";
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
64
73
|
export function isAllowedHtmlUrl(
|
|
65
74
|
elementName: string,
|
|
66
75
|
attributeName: string,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isHtmlInlineStyleAttribute,
|
|
11
11
|
isHtmlUrlAttributeName,
|
|
12
12
|
isHtmlUrlBearingAttribute,
|
|
13
|
+
isExternalHttpsImageSource,
|
|
13
14
|
} from "../../shared/html/policy";
|
|
14
15
|
|
|
15
16
|
export interface HtmlParseOk {
|
|
@@ -131,14 +132,6 @@ function templateContent(node: unknown): unknown | null {
|
|
|
131
132
|
return isRecord(node) && isRecord(node.content) ? node.content : null;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
function isHttpsUrl(value: string): boolean {
|
|
135
|
-
try {
|
|
136
|
-
return new URL(value.trim()).protocol.toLowerCase() === "https:";
|
|
137
|
-
} catch {
|
|
138
|
-
return false;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
135
|
function isWhitespace(character: string): boolean {
|
|
143
136
|
return (
|
|
144
137
|
character === " " ||
|
|
@@ -190,7 +183,11 @@ function validateUrl(
|
|
|
190
183
|
);
|
|
191
184
|
continue;
|
|
192
185
|
}
|
|
193
|
-
if (
|
|
186
|
+
if (
|
|
187
|
+
countStructure &&
|
|
188
|
+
(tagName === "img" || tagName === "source") &&
|
|
189
|
+
isExternalHttpsImageSource(candidate)
|
|
190
|
+
) {
|
|
194
191
|
counts.externalImageCount += 1;
|
|
195
192
|
}
|
|
196
193
|
}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { Lexer, type Token, type Tokens } from "marked";
|
|
30
30
|
|
|
31
31
|
import type { DiscriminativeError, VerdictObserved } from "../../shared/contracts/validation";
|
|
32
|
+
import { isExternalHttpsImageSource } from "../../shared/html/policy";
|
|
32
33
|
import { countMermaidNodeDeclarations } from "../../shared/util/mermaid-nodes";
|
|
33
34
|
import { parseMermaidText } from "./mermaid";
|
|
34
35
|
|
|
@@ -58,17 +59,6 @@ interface MarkdownCounts {
|
|
|
58
59
|
mermaidBodies: string[];
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
function isExternalHttpsUrl(value: string): boolean {
|
|
62
|
-
// The URL constructor canonicalizes the protocol; malformed URLs
|
|
63
|
-
// (e.g. `https://[`) return false here rather than throwing, so the
|
|
64
|
-
// token walk surfaces a typed zero instead of crashing Tier 0.
|
|
65
|
-
try {
|
|
66
|
-
return new URL(value.trim()).protocol.toLowerCase() === "https:";
|
|
67
|
-
} catch {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
62
|
/**
|
|
73
63
|
* Walk the marked token tree and tally the surfaces that matter for
|
|
74
64
|
* the Tier 0 verdict: fenced blocks, mermaid blocks, raw HTML blocks,
|
|
@@ -122,7 +112,7 @@ function walkTokens(tokens: Token[], counts: MarkdownCounts): void {
|
|
|
122
112
|
if (/\b(?:href|src)\s*=\s*["']https?:/i.test(raw)) counts.hasExternalRef = true;
|
|
123
113
|
} else if (token.type === "image") {
|
|
124
114
|
const image = token as Tokens.Image;
|
|
125
|
-
if (
|
|
115
|
+
if (isExternalHttpsImageSource(image.href)) counts.externalImageCount += 1;
|
|
126
116
|
}
|
|
127
117
|
// Recurse every container Marked actually emits with children.
|
|
128
118
|
const recurseTokens = (sub: Token[] | undefined): void => {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { ProtocolObservation } from "../../shared/contracts/validation";
|
|
22
|
-
import { HTML_STRUCTURAL_GROUPS } from "../../shared/html/policy";
|
|
22
|
+
import { HTML_STRUCTURAL_GROUPS, isExternalHttpsImageSource } from "../../shared/html/policy";
|
|
23
23
|
|
|
24
24
|
import type { VerifierCdpSession } from "./browser-process";
|
|
25
25
|
|
|
@@ -32,27 +32,37 @@ export async function probeIsolatedCounts(
|
|
|
32
32
|
const selectors = Object.fromEntries(
|
|
33
33
|
Object.entries(HTML_STRUCTURAL_GROUPS).map(([group, names]) => [group, names.join(",")]),
|
|
34
34
|
);
|
|
35
|
+
const externalHttpsImageSource = isExternalHttpsImageSource.toString();
|
|
35
36
|
const result = (await session.send("Runtime.evaluate", {
|
|
36
37
|
contextId: executionContextId,
|
|
37
38
|
returnByValue: true,
|
|
38
39
|
expression: [
|
|
39
40
|
"(function(){",
|
|
40
41
|
" var candidates = Array.prototype.slice.call(document.querySelectorAll('[data-facet-renderer-root=\"true\"]'));",
|
|
41
|
-
" var
|
|
42
|
-
" var
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
" }",
|
|
42
|
+
" var svgCandidates = candidates.filter(function(root){ return String(root.nodeName).toLowerCase() === 'svg'; });",
|
|
43
|
+
" var svgCandidateSet = new Set(svgCandidates);",
|
|
44
|
+
" var svgRoots = svgCandidates.filter(function(root){",
|
|
45
|
+
" for (var parent = root.parentElement; parent; parent = parent.parentElement) if (svgCandidateSet.has(parent)) return false;",
|
|
46
46
|
" return true;",
|
|
47
47
|
" });",
|
|
48
|
-
" var
|
|
48
|
+
" var contentCandidates = candidates.filter(function(root){ return String(root.nodeName).toLowerCase() !== 'svg'; });",
|
|
49
|
+
" var contentCandidateSet = new Set(contentCandidates);",
|
|
50
|
+
" var contentRoots = contentCandidates.filter(function(root){",
|
|
51
|
+
" for (var parent = root.parentElement; parent; parent = parent.parentElement) if (contentCandidateSet.has(parent)) return false;",
|
|
52
|
+
" return true;",
|
|
53
|
+
" });",
|
|
54
|
+
" var htmlRoots = contentRoots.filter(function(root){ return root.getAttribute('data-facet-renderer-kind') !== 'markdown'; });",
|
|
49
55
|
" var graphRoots = svgRoots.filter(function(root){ return root.getAttribute('data-facet-renderer-graph') === 'true'; });",
|
|
50
|
-
" var htmlRoots = roots.filter(function(root){ return String(root.nodeName).toLowerCase() !== 'svg'; });",
|
|
51
56
|
` var observeContent = ${JSON.stringify(observeContent)};`,
|
|
52
|
-
|
|
57
|
+
` var isExternalHttpsImageSource = (${externalHttpsImageSource});`,
|
|
58
|
+
" var emptyRendererRoot = observeContent && contentRoots.length === 1 ? Array.prototype.every.call(contentRoots[0].childNodes, function(node){ return node.nodeType !== 1 && (node.nodeType !== 3 || !node.textContent.trim()); }) : undefined;",
|
|
53
59
|
` var selectors = ${JSON.stringify(selectors)};`,
|
|
54
60
|
" var html = htmlRoots.length === 0 ? null : {rendererRootCount:htmlRoots.length,headingCount:0,tableCount:0,listCount:0,imageCount:0,canvasCount:0,externalImageCount:0};",
|
|
55
61
|
" var externalImageCount = 0;",
|
|
62
|
+
" for (var c = 0; c < contentRoots.length; c++) {",
|
|
63
|
+
" var contentImages = Array.prototype.slice.call(contentRoots[c].querySelectorAll(selectors.images));",
|
|
64
|
+
" externalImageCount += contentImages.filter(function(image){ return isExternalHttpsImageSource(image.getAttribute('src')); }).length;",
|
|
65
|
+
" }",
|
|
56
66
|
" for (var h = 0; h < htmlRoots.length; h++) {",
|
|
57
67
|
" var htmlRoot = htmlRoots[h];",
|
|
58
68
|
" html.headingCount += htmlRoot.querySelectorAll(selectors.headings).length;",
|
|
@@ -60,9 +70,8 @@ export async function probeIsolatedCounts(
|
|
|
60
70
|
" html.listCount += htmlRoot.querySelectorAll(selectors.lists).length;",
|
|
61
71
|
" var images = Array.prototype.slice.call(htmlRoot.querySelectorAll(selectors.images));",
|
|
62
72
|
" html.imageCount += images.length;",
|
|
63
|
-
" var rootExternal = images.filter(function(image){
|
|
73
|
+
" var rootExternal = images.filter(function(image){ return isExternalHttpsImageSource(image.getAttribute('src')); }).length;",
|
|
64
74
|
" html.externalImageCount += rootExternal;",
|
|
65
|
-
" externalImageCount += rootExternal;",
|
|
66
75
|
" html.canvasCount += htmlRoot.querySelectorAll(selectors.canvases).length;",
|
|
67
76
|
" }",
|
|
68
77
|
" var nodeCount = 0;",
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resource caps for the Tier 1 verifier.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Named limits keep the runner, harness builder, and penetration test
|
|
5
|
+
* aligned. Enforced caps produce typed `FacetError`s with matching
|
|
6
|
+
* `tier1_*` codes; TIER1_TIMEOUT_MS remains an intended total-budget
|
|
7
|
+
* target until outer enforcement lands.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
*
|
|
11
|
+
* Intended total budget target for one Tier 1 verifier invocation; it
|
|
12
|
+
* is not enforced yet. The harness
|
|
11
13
|
* bundles the REAL renderers (mermaid + marked + vega, ~8 MB inline);
|
|
12
14
|
* parse + first render of a 40-node fixture needs real headroom.
|
|
13
15
|
*/
|
|
@@ -36,10 +38,13 @@ export const TIER1_USER_DATA_DIR_MODE = 0o700;
|
|
|
36
38
|
*/
|
|
37
39
|
export const TIER1_RENDER_BARRIER_MS = 30_000;
|
|
38
40
|
|
|
41
|
+
/** Keep tiled evidence within the intended total-budget target after render and stability windows. */
|
|
42
|
+
export const TIER1_TILED_CAPTURE_DEADLINE_MS = 15_000;
|
|
43
|
+
|
|
39
44
|
/**
|
|
40
45
|
* Time between the first interactive TSX observation and its bounded
|
|
41
|
-
* stability re-check. It stays below the
|
|
42
|
-
*
|
|
46
|
+
* stability re-check. It stays below the intended total-budget target after
|
|
47
|
+
* the render barrier; that outer target is not enforced yet.
|
|
43
48
|
*/
|
|
44
49
|
export const TSX_STABILITY_WINDOW_MS = 1_000;
|
|
45
50
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import type { HtmlStructureCounts, ProtocolObservation } from "../../shared/contracts/validation";
|
|
23
|
-
import { HTML_STRUCTURAL_GROUPS } from "../../shared/html/policy";
|
|
23
|
+
import { HTML_STRUCTURAL_GROUPS, isExternalHttpsImageSource } from "../../shared/html/policy";
|
|
24
24
|
|
|
25
25
|
import type { VerifierCdpSession } from "./browser-process";
|
|
26
26
|
import type { ResolvedChildFrame } from "./frame-target";
|
|
@@ -49,7 +49,7 @@ export interface SnapshotDocument {
|
|
|
49
49
|
function snapshotRootEmpty(snapshot: SnapshotResponse, documentIndex: number): boolean | undefined {
|
|
50
50
|
const document = snapshot.documents[documentIndex];
|
|
51
51
|
if (document === undefined) return undefined;
|
|
52
|
-
const roots =
|
|
52
|
+
const roots = contentRootIndexes(snapshot, documentIndex);
|
|
53
53
|
if (roots.length !== 1) return undefined;
|
|
54
54
|
const root = roots[0];
|
|
55
55
|
if (root === undefined) return undefined;
|
|
@@ -166,6 +166,18 @@ function hasAncestorIn(
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
function htmlRootIndexes(snapshot: SnapshotResponse, documentIndex: number): number[] {
|
|
169
|
+
return contentRootIndexes(snapshot, documentIndex).filter(
|
|
170
|
+
(nodeIndex) =>
|
|
171
|
+
attributeValue(
|
|
172
|
+
snapshot,
|
|
173
|
+
snapshot.documents[documentIndex]!,
|
|
174
|
+
nodeIndex,
|
|
175
|
+
"data-facet-renderer-kind",
|
|
176
|
+
) !== "markdown",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function contentRootIndexes(snapshot: SnapshotResponse, documentIndex: number): number[] {
|
|
169
181
|
const document = snapshot.documents[documentIndex];
|
|
170
182
|
if (document === undefined) return [];
|
|
171
183
|
const candidates = new Set<number>();
|
|
@@ -181,6 +193,27 @@ function htmlRootIndexes(snapshot: SnapshotResponse, documentIndex: number): num
|
|
|
181
193
|
});
|
|
182
194
|
}
|
|
183
195
|
|
|
196
|
+
function countSnapshotExternalImages(snapshot: SnapshotResponse, documentIndex: number): number {
|
|
197
|
+
const document = snapshot.documents[documentIndex];
|
|
198
|
+
if (document === undefined) return 0;
|
|
199
|
+
const roots = new Set(contentRootIndexes(snapshot, documentIndex));
|
|
200
|
+
let count = 0;
|
|
201
|
+
for (let nodeIndex = 0; nodeIndex < document.nodes.nodeName.length; nodeIndex += 1) {
|
|
202
|
+
if (!isDescendantOf(document, nodeIndex, roots)) continue;
|
|
203
|
+
const name = readString(
|
|
204
|
+
snapshot.strings,
|
|
205
|
+
document.nodes.nodeName[nodeIndex] ?? 0,
|
|
206
|
+
).toLowerCase();
|
|
207
|
+
if (
|
|
208
|
+
(HTML_STRUCTURAL_GROUPS.images as readonly string[]).includes(name) &&
|
|
209
|
+
isExternalHttpsImageSource(attributeValue(snapshot, document, nodeIndex, "src"))
|
|
210
|
+
) {
|
|
211
|
+
count += 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return count;
|
|
215
|
+
}
|
|
216
|
+
|
|
184
217
|
function isDescendantOf(
|
|
185
218
|
document: SnapshotDocument,
|
|
186
219
|
nodeIndex: number,
|
|
@@ -194,15 +227,6 @@ function isDescendantOf(
|
|
|
194
227
|
return false;
|
|
195
228
|
}
|
|
196
229
|
|
|
197
|
-
function isExternalHttps(value: string | undefined): boolean {
|
|
198
|
-
if (value === undefined) return false;
|
|
199
|
-
try {
|
|
200
|
-
return new URL(value).protocol === "https:";
|
|
201
|
-
} catch {
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
230
|
export function countSnapshotHtml(
|
|
207
231
|
snapshot: SnapshotResponse,
|
|
208
232
|
documentIndex: number,
|
|
@@ -233,7 +257,7 @@ export function countSnapshotHtml(
|
|
|
233
257
|
if ((HTML_STRUCTURAL_GROUPS.lists as readonly string[]).includes(name)) counts.listCount += 1;
|
|
234
258
|
if ((HTML_STRUCTURAL_GROUPS.images as readonly string[]).includes(name)) {
|
|
235
259
|
counts.imageCount += 1;
|
|
236
|
-
if (
|
|
260
|
+
if (isExternalHttpsImageSource(attributeValue(snapshot, document, nodeIndex, "src"))) {
|
|
237
261
|
counts.externalImageCount += 1;
|
|
238
262
|
}
|
|
239
263
|
}
|
|
@@ -372,6 +396,7 @@ export async function probeProtocolSnapshot(
|
|
|
372
396
|
const discriminativeErrors = collectDiscriminativeErrors(snapshot, documentIndex);
|
|
373
397
|
const errorCount = discriminativeErrors.length;
|
|
374
398
|
const htmlCounts = countSnapshotHtml(snapshot, documentIndex);
|
|
399
|
+
const externalImageCount = countSnapshotExternalImages(snapshot, documentIndex);
|
|
375
400
|
const emptyRendererRoot = observeContent ? snapshotRootEmpty(snapshot, documentIndex) : undefined;
|
|
376
401
|
return {
|
|
377
402
|
rendererRootSvgCount: rendererRoots.length,
|
|
@@ -379,7 +404,7 @@ export async function probeProtocolSnapshot(
|
|
|
379
404
|
mermaidNodeCount: countGNode(snapshot, documentIndex, graphRoots),
|
|
380
405
|
visibleSvgCount: viewBoxes.filter(isNonDegenerateViewBox).length,
|
|
381
406
|
opaqueRegionCount: countByName(snapshot, documentIndex, "canvas"),
|
|
382
|
-
externalImageCount
|
|
407
|
+
externalImageCount,
|
|
383
408
|
...(htmlCounts === undefined ? {} : { html: htmlCounts }),
|
|
384
409
|
...(emptyRendererRoot === undefined ? {} : { emptyRendererRoot }),
|
|
385
410
|
viewBoxes,
|
|
@@ -424,6 +449,7 @@ export async function probeProtocolGetDocument(
|
|
|
424
449
|
let gNodeCount = 0;
|
|
425
450
|
let opaqueRegionCount = 0;
|
|
426
451
|
let visibleSvgCount = 0;
|
|
452
|
+
let externalImageCount = 0;
|
|
427
453
|
let html: HtmlStructureCounts | undefined;
|
|
428
454
|
let emptyRendererRoot: boolean | undefined;
|
|
429
455
|
const viewBoxes: string[] = [];
|
|
@@ -458,19 +484,21 @@ export async function probeProtocolGetDocument(
|
|
|
458
484
|
const name = (record.nodeName ?? "").toLowerCase();
|
|
459
485
|
const markedRoot = findAttr("data-facet-renderer-root") === "true";
|
|
460
486
|
const rendererRoot = name === "svg" && markedRoot && !withinRendererRoot;
|
|
461
|
-
const
|
|
487
|
+
const contentRoot = name !== "svg" && markedRoot && !withinMarkedRoot;
|
|
488
|
+
const markdownRoot = contentRoot && findAttr("data-facet-renderer-kind") === "markdown";
|
|
489
|
+
const htmlRoot = contentRoot && !markdownRoot;
|
|
462
490
|
const graphRoot = rendererRoot && findAttr("data-facet-renderer-graph") === "true";
|
|
491
|
+
if (contentRoot && observeContent) {
|
|
492
|
+
const empty = !(record.children ?? []).some((child) => {
|
|
493
|
+
const direct = child as ProtocolDomNode & { nodeType?: number; nodeValue?: string };
|
|
494
|
+
return (
|
|
495
|
+
direct.nodeType === 1 ||
|
|
496
|
+
(direct.nodeType === 3 && (direct.nodeValue ?? "").trim().length > 0)
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
emptyRendererRoot = emptyRendererRoot === undefined ? empty : false;
|
|
500
|
+
}
|
|
463
501
|
if (htmlRoot) {
|
|
464
|
-
if (observeContent) {
|
|
465
|
-
const empty = !(record.children ?? []).some((child) => {
|
|
466
|
-
const direct = child as ProtocolDomNode & { nodeType?: number; nodeValue?: string };
|
|
467
|
-
return (
|
|
468
|
-
direct.nodeType === 1 ||
|
|
469
|
-
(direct.nodeType === 3 && (direct.nodeValue ?? "").trim().length > 0)
|
|
470
|
-
);
|
|
471
|
-
});
|
|
472
|
-
emptyRendererRoot = emptyRendererRoot === undefined ? empty : false;
|
|
473
|
-
}
|
|
474
502
|
html ??= {
|
|
475
503
|
rendererRootCount: 0,
|
|
476
504
|
headingCount: 0,
|
|
@@ -498,6 +526,12 @@ export async function probeProtocolGetDocument(
|
|
|
498
526
|
// covers the entire child-frame document so smuggled canvases stay visible.
|
|
499
527
|
// getContext() would create a context and make the observation self-fulfilling.
|
|
500
528
|
if (name === "canvas") opaqueRegionCount += 1;
|
|
529
|
+
if (
|
|
530
|
+
(withinMarkedRoot || contentRoot) &&
|
|
531
|
+
(HTML_STRUCTURAL_GROUPS.images as readonly string[]).includes(name)
|
|
532
|
+
) {
|
|
533
|
+
if (isExternalHttpsImageSource(findAttr("src"))) externalImageCount += 1;
|
|
534
|
+
}
|
|
501
535
|
if (withinHtmlRoot && html !== undefined) {
|
|
502
536
|
if ((HTML_STRUCTURAL_GROUPS.headings as readonly string[]).includes(name))
|
|
503
537
|
html.headingCount += 1;
|
|
@@ -505,7 +539,7 @@ export async function probeProtocolGetDocument(
|
|
|
505
539
|
if ((HTML_STRUCTURAL_GROUPS.lists as readonly string[]).includes(name)) html.listCount += 1;
|
|
506
540
|
if ((HTML_STRUCTURAL_GROUPS.images as readonly string[]).includes(name)) {
|
|
507
541
|
html.imageCount += 1;
|
|
508
|
-
if (
|
|
542
|
+
if (isExternalHttpsImageSource(findAttr("src"))) html.externalImageCount += 1;
|
|
509
543
|
}
|
|
510
544
|
if ((HTML_STRUCTURAL_GROUPS.canvases as readonly string[]).includes(name))
|
|
511
545
|
html.canvasCount += 1;
|
|
@@ -545,7 +579,7 @@ export async function probeProtocolGetDocument(
|
|
|
545
579
|
mermaidNodeCount: gNodeCount,
|
|
546
580
|
visibleSvgCount,
|
|
547
581
|
opaqueRegionCount,
|
|
548
|
-
externalImageCount
|
|
582
|
+
externalImageCount,
|
|
549
583
|
...(html === undefined ? {} : { html }),
|
|
550
584
|
...(emptyRendererRoot === undefined ? {} : { emptyRendererRoot }),
|
|
551
585
|
viewBoxes,
|
|
@@ -31,6 +31,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
31
31
|
import { tmpdir } from "node:os";
|
|
32
32
|
import { join } from "node:path";
|
|
33
33
|
import { Buffer } from "node:buffer";
|
|
34
|
+
import sharp from "sharp";
|
|
34
35
|
|
|
35
36
|
import {
|
|
36
37
|
Tier1ResultSchema,
|
|
@@ -73,6 +74,7 @@ import {
|
|
|
73
74
|
TIER1_SCREENSHOT_MAX_AXIS_PX,
|
|
74
75
|
TIER1_SCREENSHOT_MAX_PIXELS,
|
|
75
76
|
TIER1_SCREENSHOT_WEBP_QUALITY,
|
|
77
|
+
TIER1_TILED_CAPTURE_DEADLINE_MS,
|
|
76
78
|
TIER1_VIEWPORT_HEIGHT,
|
|
77
79
|
TIER1_VIEWPORT_WIDTH,
|
|
78
80
|
TSX_STABILITY_WINDOW_MS,
|
|
@@ -327,7 +329,7 @@ async function runTier1Attempt(
|
|
|
327
329
|
artifactFrame,
|
|
328
330
|
isolated.executionContextId,
|
|
329
331
|
interactiveTsx ? runtimeExceptions!.errorsForFrame(artifactFrame.frameId) : [],
|
|
330
|
-
input.artifactType === "tsx",
|
|
332
|
+
input.artifactType === "tsx" || input.artifactType === "markdown",
|
|
331
333
|
);
|
|
332
334
|
const secondObservation = interactiveTsx
|
|
333
335
|
? await waitForStabilityObservation(
|
|
@@ -353,6 +355,7 @@ async function runTier1Attempt(
|
|
|
353
355
|
renderComplete: shim.renderComplete,
|
|
354
356
|
interactive: interactiveTsx,
|
|
355
357
|
tsx: input.artifactType === "tsx",
|
|
358
|
+
markdown: input.artifactType === "markdown",
|
|
356
359
|
channelDivergence,
|
|
357
360
|
structureChanged:
|
|
358
361
|
interactiveTsx && countsDiffer(firstObservation.protocol, secondObservation.protocol),
|
|
@@ -976,6 +979,165 @@ export async function captureEvidenceScreenshot(
|
|
|
976
979
|
return captureScreenshotWithRetry(session, { bounds: options.bounds });
|
|
977
980
|
}
|
|
978
981
|
|
|
982
|
+
async function captureTiledScreenshot(
|
|
983
|
+
session: VerifierCdpSession,
|
|
984
|
+
executionContextId: number,
|
|
985
|
+
options: {
|
|
986
|
+
readonly tileTimeoutMs?: number;
|
|
987
|
+
readonly tileAttempts?: number;
|
|
988
|
+
readonly tiledDeadlineMs?: number;
|
|
989
|
+
} = {},
|
|
990
|
+
): Promise<CapturedEvidenceImage> {
|
|
991
|
+
const deadlineAt =
|
|
992
|
+
performance.now() + (options.tiledDeadlineMs ?? TIER1_TILED_CAPTURE_DEADLINE_MS);
|
|
993
|
+
const assertDeadline = () => {
|
|
994
|
+
if (performance.now() >= deadlineAt) throw new Error("tiled-capture deadline exceeded");
|
|
995
|
+
};
|
|
996
|
+
await configureTier1Viewport(session);
|
|
997
|
+
await session.send("Runtime.evaluate", {
|
|
998
|
+
contextId: executionContextId,
|
|
999
|
+
expression: "new Promise(requestAnimationFrame)",
|
|
1000
|
+
awaitPromise: true,
|
|
1001
|
+
});
|
|
1002
|
+
const source = await measureArtifactCaptureSize(session, executionContextId);
|
|
1003
|
+
const bounds = boundCaptureSize(source);
|
|
1004
|
+
const viewport = (await session.send("Runtime.evaluate", {
|
|
1005
|
+
contextId: executionContextId,
|
|
1006
|
+
returnByValue: true,
|
|
1007
|
+
expression:
|
|
1008
|
+
"(function(){var element=document.getElementById('artifact');" +
|
|
1009
|
+
"if(!element)return null;return {width:element.clientWidth,height:element.clientHeight}})()",
|
|
1010
|
+
})) as { result: { value?: { width: number; height: number } | null } };
|
|
1011
|
+
const visible = viewport.result.value;
|
|
1012
|
+
if (!visible || visible.width <= 0 || visible.height <= 0)
|
|
1013
|
+
throw new Error("artifact tile viewport unavailable");
|
|
1014
|
+
const owner = (await session.send("Runtime.evaluate", {
|
|
1015
|
+
returnByValue: true,
|
|
1016
|
+
expression:
|
|
1017
|
+
"(function(){var frame=document.querySelector('#host-root iframe');" +
|
|
1018
|
+
"if(!frame)return null;var rect=frame.getBoundingClientRect();" +
|
|
1019
|
+
"return {x:rect.left,y:rect.top,width:rect.width,height:rect.height}})()",
|
|
1020
|
+
})) as { result: { value?: { x: number; y: number; width: number; height: number } | null } };
|
|
1021
|
+
const frame = owner.result.value;
|
|
1022
|
+
if (!frame || frame.x < 0 || frame.y < 0)
|
|
1023
|
+
throw new Error("artifact frame capture box unavailable");
|
|
1024
|
+
const tileWidth = Math.min(Math.max(1, Math.floor(visible.width)), Math.floor(frame.width));
|
|
1025
|
+
const tileHeight = Math.min(Math.max(1, Math.floor(visible.height)), Math.floor(frame.height));
|
|
1026
|
+
if (tileWidth <= 0 || tileHeight <= 0) throw new Error("artifact tile bounds unavailable");
|
|
1027
|
+
const columns = Math.ceil(source.width / tileWidth);
|
|
1028
|
+
const rows = Math.ceil(source.height / tileHeight);
|
|
1029
|
+
if (columns * rows > 256) throw new Error("artifact requires too many screenshot tiles");
|
|
1030
|
+
const layers: { input: Buffer; left: number; top: number }[] = [];
|
|
1031
|
+
for (let row = 0; row < rows; row += 1) {
|
|
1032
|
+
for (let column = 0; column < columns; column += 1) {
|
|
1033
|
+
assertDeadline();
|
|
1034
|
+
const x = column * tileWidth;
|
|
1035
|
+
const y = row * tileHeight;
|
|
1036
|
+
const scrolled = (await session.send("Runtime.evaluate", {
|
|
1037
|
+
contextId: executionContextId,
|
|
1038
|
+
returnByValue: true,
|
|
1039
|
+
expression:
|
|
1040
|
+
"(function(){var element=document.getElementById('artifact');" +
|
|
1041
|
+
"element.style.scrollBehavior='auto';element.scrollLeft=" +
|
|
1042
|
+
x +
|
|
1043
|
+
";element.scrollTop=" +
|
|
1044
|
+
y +
|
|
1045
|
+
";return new Promise(function(resolve){requestAnimationFrame(function(){resolve({left:element.scrollLeft,top:element.scrollTop})})})})()",
|
|
1046
|
+
awaitPromise: true,
|
|
1047
|
+
})) as { result: { value?: { left: number; top: number } } };
|
|
1048
|
+
const offset = scrolled.result.value;
|
|
1049
|
+
if (!offset || offset.left > x || offset.top > y)
|
|
1050
|
+
throw new Error("artifact tile scroll position unavailable");
|
|
1051
|
+
const tile = await captureScreenshotWithRetry(session, {
|
|
1052
|
+
attempts: options.tileAttempts ?? TIER1_SCREENSHOT_CAPTURE_ATTEMPTS,
|
|
1053
|
+
timeoutMs: options.tileTimeoutMs ?? TIER1_SCREENSHOT_CAPTURE_TIMEOUT_MS,
|
|
1054
|
+
capture: async (cdpSession) => {
|
|
1055
|
+
const shot = (await cdpSession.send("Page.captureScreenshot", {
|
|
1056
|
+
format: "png",
|
|
1057
|
+
captureBeyondViewport: false,
|
|
1058
|
+
clip: {
|
|
1059
|
+
x: Math.floor(frame.x),
|
|
1060
|
+
y: Math.floor(frame.y),
|
|
1061
|
+
width: tileWidth,
|
|
1062
|
+
height: tileHeight,
|
|
1063
|
+
scale: 1,
|
|
1064
|
+
},
|
|
1065
|
+
})) as { data?: string };
|
|
1066
|
+
return shot.data ? { bytes: Buffer.from(shot.data, "base64"), format: "png" } : null;
|
|
1067
|
+
},
|
|
1068
|
+
});
|
|
1069
|
+
if (tile.screenshot === null)
|
|
1070
|
+
throw new Error(`tiled screenshot capture failed: ${tile.screenshotError?.message}`);
|
|
1071
|
+
const outputLeft = Math.floor(x * bounds.scale);
|
|
1072
|
+
const outputTop = Math.floor(y * bounds.scale);
|
|
1073
|
+
const outputWidth =
|
|
1074
|
+
Math.min(bounds.width, Math.floor(Math.min(x + tileWidth, source.width) * bounds.scale)) -
|
|
1075
|
+
outputLeft;
|
|
1076
|
+
const outputHeight =
|
|
1077
|
+
Math.min(
|
|
1078
|
+
bounds.height,
|
|
1079
|
+
Math.floor(Math.min(y + tileHeight, source.height) * bounds.scale),
|
|
1080
|
+
) - outputTop;
|
|
1081
|
+
if (outputWidth <= 0 || outputHeight <= 0) continue;
|
|
1082
|
+
const input = await sharp(tile.screenshot.bytes)
|
|
1083
|
+
.extract({
|
|
1084
|
+
left: x - offset.left,
|
|
1085
|
+
top: y - offset.top,
|
|
1086
|
+
width: Math.min(tileWidth, source.width - x),
|
|
1087
|
+
height: Math.min(tileHeight, source.height - y),
|
|
1088
|
+
})
|
|
1089
|
+
.resize(outputWidth, outputHeight, { fit: "fill" })
|
|
1090
|
+
.png()
|
|
1091
|
+
.toBuffer();
|
|
1092
|
+
layers.push({ input, left: outputLeft, top: outputTop });
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
assertDeadline();
|
|
1096
|
+
const finalSize = await measureArtifactCaptureSize(session, executionContextId);
|
|
1097
|
+
await session.send("Runtime.evaluate", {
|
|
1098
|
+
contextId: executionContextId,
|
|
1099
|
+
expression:
|
|
1100
|
+
"(function(){var element=document.getElementById('artifact');element.scrollLeft=0;element.scrollTop=0})()",
|
|
1101
|
+
});
|
|
1102
|
+
if (finalSize.width > source.width || finalSize.height > source.height)
|
|
1103
|
+
throw new Error("artifact grew during tiled screenshot capture");
|
|
1104
|
+
const bytes = await sharp({
|
|
1105
|
+
create: { width: bounds.width, height: bounds.height, channels: 3, background: "#151823" },
|
|
1106
|
+
})
|
|
1107
|
+
.composite(layers)
|
|
1108
|
+
.webp({ quality: TIER1_SCREENSHOT_WEBP_QUALITY })
|
|
1109
|
+
.toBuffer();
|
|
1110
|
+
if (bytes.byteLength > TIER1_SCREENSHOT_CAP_BYTES)
|
|
1111
|
+
throw new Error("tiled screenshot exceeds encoded-size cap");
|
|
1112
|
+
assertDeadline();
|
|
1113
|
+
return { bytes, format: "webp" };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
export async function captureTiledEvidenceScreenshot(
|
|
1117
|
+
session: VerifierCdpSession,
|
|
1118
|
+
executionContextId: number,
|
|
1119
|
+
options: {
|
|
1120
|
+
readonly tileTimeoutMs?: number;
|
|
1121
|
+
readonly tileAttempts?: number;
|
|
1122
|
+
readonly tiledDeadlineMs?: number;
|
|
1123
|
+
} = {},
|
|
1124
|
+
): Promise<{
|
|
1125
|
+
readonly screenshot: CapturedEvidenceImage | null;
|
|
1126
|
+
readonly screenshotError: ScreenshotError | null;
|
|
1127
|
+
}> {
|
|
1128
|
+
try {
|
|
1129
|
+
return {
|
|
1130
|
+
screenshot: await captureTiledScreenshot(session, executionContextId, options),
|
|
1131
|
+
screenshotError: null,
|
|
1132
|
+
};
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
return {
|
|
1135
|
+
screenshot: null,
|
|
1136
|
+
screenshotError: { code: "screenshot_unavailable", message: screenshotFailureMessage(error) },
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
979
1141
|
interface ScreenshotRetryOptions {
|
|
980
1142
|
readonly attempts?: number;
|
|
981
1143
|
readonly timeoutMs?: number;
|
|
@@ -1092,15 +1254,16 @@ async function captureEvidence(
|
|
|
1092
1254
|
returnByValue: true,
|
|
1093
1255
|
});
|
|
1094
1256
|
const finalSize = await measureArtifactCaptureSize(target.session, options.executionContextId);
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
const
|
|
1098
|
-
const
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1257
|
+
// The host iframe clips beyond its own 800px viewport; a larger iframe would
|
|
1258
|
+
// resize 100vh descendants and never converge. Tiles preserve that viewport.
|
|
1259
|
+
const grew = finalSize.width > initialSize.width || finalSize.height > initialSize.height;
|
|
1260
|
+
const capture = grew
|
|
1261
|
+
? await captureTiledEvidenceScreenshot(target.session, options.executionContextId)
|
|
1262
|
+
: await captureEvidenceScreenshot(target.session, {
|
|
1263
|
+
animated,
|
|
1264
|
+
bounds: { bounds: boundCaptureSize(finalSize), source: finalSize },
|
|
1265
|
+
...(captureScreenshot === undefined ? {} : { captureStatic: captureScreenshot }),
|
|
1266
|
+
});
|
|
1104
1267
|
screenshotError = capture.screenshotError;
|
|
1105
1268
|
const screenshot = capture.screenshot;
|
|
1106
1269
|
if (screenshot !== null) {
|
|
@@ -88,6 +88,8 @@ export interface LifecycleSummary {
|
|
|
88
88
|
readonly channelDivergence?: boolean;
|
|
89
89
|
/** Interactive TSX has no lexical HTML prediction and no trusted outer shim. */
|
|
90
90
|
readonly interactive?: boolean;
|
|
91
|
+
/** Markdown can have no layout-bearing roots while still containing readable prose. */
|
|
92
|
+
readonly markdown?: boolean;
|
|
91
93
|
/** Only TSX has a renderer-root emptiness claim from Tier 1. */
|
|
92
94
|
readonly tsx?: boolean;
|
|
93
95
|
}
|
|
@@ -166,6 +168,13 @@ export function deriveVerdict(
|
|
|
166
168
|
if (
|
|
167
169
|
!lifecycle.interactive &&
|
|
168
170
|
expected.html === undefined &&
|
|
171
|
+
!(
|
|
172
|
+
lifecycle.markdown === true &&
|
|
173
|
+
expected.rendererRootSvgCount === 0 &&
|
|
174
|
+
expected.opaqueRegionCount === 0 &&
|
|
175
|
+
protocolObservation.emptyRendererRoot === false &&
|
|
176
|
+
isolatedObservation?.emptyRendererRoot === false
|
|
177
|
+
) &&
|
|
169
178
|
!layoutObservable(protocolObservation)
|
|
170
179
|
) {
|
|
171
180
|
return "partial:layout_unverified";
|