Wave 4: reveal settling + replay, 206/magic-byte media fix, hidden-geometry banding, bare-zero utility gating
Capture (4a): - stabilize: dwell-scroll settling pass + known-library pre-reveal neutralization after lazy promotion, so every viewport snapshot records the post-reveal steady state (about-page sections no longer captured blanket-hidden: 24 base `invisible` wrappers -> 0) - probeReveals: extended to the visibility + entrance-animation family; DittoMotion replays them (JS re-hide on mount, IntersectionObserver reveal, 4s force-reveal failsafe) so SSR/non-JS still shows content - assets: 206 range-response bodies are no longer stored as full assets (full-fetch fallback) + video magic-byte validation - the about-page hero video is now a valid 6.0MB ftyp mp4 instead of 18.9KB of garbage Generate (4b): - css: hidden-node geometry policy - a visibility:hidden box with a 0x0 captured bbox at a band becomes display:none there; an OCCUPYING hidden box gets the captured per-viewport geometry instead of the baked canonical one. Also covers the ancestor-hidden-at-base case (the cropin.com/cotton slider arrow: hide inherited from an elementor-widget ancestor at every width parked a desktop left:548px box inside a 375px viewport -> body.scrollWidth 585; now 375 on both verified pages) - tailwind: bare-zero utility gating - font-size:0 -> text-[0px] and letter-spacing:0 -> tracking-[0px] (text-0/tracking-0 are silently invalid in Tailwind v4; fixes permanently-visible map labels on the cotton Global-presence section) Verified: 87/87 compiler tests green, full-workspace suite green, regenerated cropin.com + /cotton/ builds measure 375px scrollWidth at a 375px viewport, and a fresh /about/ clone renders all reveal sections with scroll-replay against the live site. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9aed2540aa
commit
199359d0a6
@@ -0,0 +1,49 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Reveal fixture</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; font-family: sans-serif; }
|
||||||
|
.spacer { height: 2200px; background: linear-gradient(#eee, #ddd); }
|
||||||
|
/* Elementor pattern: content wrappers hidden at load, revealed by a waypoint class
|
||||||
|
swap that applies an entrance keyframe animation. */
|
||||||
|
.elementor-invisible { visibility: hidden; }
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from { opacity: 0; transform: translate3d(0, 40px, 0); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.animated { animation-duration: 0.4s; animation-fill-mode: both; }
|
||||||
|
.fadeInUp { animation-name: fadeInUp; }
|
||||||
|
.reveal-box { height: 220px; background: #2e8b57; color: #fff; padding: 20px; }
|
||||||
|
#never { visibility: hidden; height: 60px; background: #999; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Reveal fixture</h1>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<!-- Waypoint reveal: hidden at load, class-swapped when scrolled into view. -->
|
||||||
|
<div id="reveal-io" class="reveal-box elementor-invisible" data-io-reveal data-settings='{"_animation":"fadeInUp"}'>
|
||||||
|
<p>Scroll-revealed content (IntersectionObserver)</p>
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<!-- Same pre-reveal marker but keyed to a trigger the dwell walk cannot fire:
|
||||||
|
only the known-library neutralization reveals it. -->
|
||||||
|
<div id="reveal-far" class="reveal-box elementor-invisible" data-settings='{"_animation":"fadeInUp"}'>
|
||||||
|
<p>Reveal keyed to a non-scroll trigger</p>
|
||||||
|
</div>
|
||||||
|
<!-- Genuinely hidden, non-library content: must STAY hidden in the capture. -->
|
||||||
|
<div id="never">permanently hidden</div>
|
||||||
|
<script>
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (!e.isIntersecting) continue;
|
||||||
|
e.target.classList.remove("elementor-invisible");
|
||||||
|
e.target.classList.add("animated", "fadeInUp");
|
||||||
|
io.unobserve(e.target);
|
||||||
|
}
|
||||||
|
}, { threshold: 0.1 });
|
||||||
|
for (const el of document.querySelectorAll("[data-io-reveal]")) io.observe(el);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3012,6 +3012,22 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
|||||||
// (declsForViewport emits `visibility:hidden`; parent-equality keeps it own-only).
|
// (declsForViewport emits `visibility:hidden`; parent-equality keeps it own-only).
|
||||||
} else if (ownNone || !node.visibleByVp[b.vp]) {
|
} else if (ownNone || !node.visibleByVp[b.vp]) {
|
||||||
const shownAtBase = node.visibleByVp[baseVp] && (node.computedByVp[baseVp]?.display || "") !== "none";
|
const shownAtBase = node.visibleByVp[baseVp] && (node.computedByVp[baseVp]?.display || "") !== "none";
|
||||||
|
// Hidden by an ANCESTOR's visibility here AND at base (the ancestor's own rule carries
|
||||||
|
// the hide at every width) — but a visibility:hidden box still PARTICIPATES in layout,
|
||||||
|
// and the base rule bakes CANONICAL geometry. Same policy as the own-hidden path above:
|
||||||
|
// a 0x0 box gets display:none; an occupying box falls through to the per-viewport delta
|
||||||
|
// so it sits where the capture measured it at THIS width — not parked at e.g. a desktop
|
||||||
|
// left:548px inside a 375px viewport (the cropin.com/cotton slider arrow, +210px of
|
||||||
|
// sideways scroll at 375 with the hide inherited from an elementor-widget ancestor).
|
||||||
|
const ancestorHiddenHere = !ownNone && /^(hidden|collapse)$/.test(vpCs.visibility || "");
|
||||||
|
if (ancestorHiddenHere && !shownAtBase) {
|
||||||
|
const bb = node.bboxByVp[b.vp];
|
||||||
|
if (!bb || bb.width <= 0 || bb.height <= 0) {
|
||||||
|
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Occupying: fall through to the normal per-viewport delta below.
|
||||||
|
} else {
|
||||||
if (ownNone) {
|
if (ownNone) {
|
||||||
// Own display:none removes the box from layout entirely. Emit the hide even when the node
|
// Own display:none removes the box from layout entirely. Emit the hide even when the node
|
||||||
// is ALSO hidden at base — a visibility:hidden base still bakes an OCCUPYING box (see
|
// is ALSO hidden at base — a visibility:hidden base still bakes an OCCUPYING box (see
|
||||||
@@ -3021,9 +3037,9 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
|||||||
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
|
nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) });
|
||||||
}
|
}
|
||||||
} else if (shownAtBase) {
|
} else if (shownAtBase) {
|
||||||
// Hidden by an ancestor (or zero-size / opacity:0): geometry overrides are breakpoint
|
// Hidden by an ancestor (or zero-size / opacity:0) but visible at base: geometry
|
||||||
// noise — the ancestor's own hide (or the reveal replay, for scroll-reveal opacity)
|
// overrides are breakpoint noise — the ancestor's own hide (or the reveal replay,
|
||||||
// covers it. Emit only the hide the node itself carries.
|
// for scroll-reveal opacity) covers it. Emit only the hide the node itself carries.
|
||||||
const hide = new Map<string, string>();
|
const hide = new Map<string, string>();
|
||||||
if (pf(vpCs.opacity) === 0 && !animOwned.has("opacity")) hide.set("opacity", "0");
|
if (pf(vpCs.opacity) === 0 && !animOwned.has("opacity")) hide.set("opacity", "0");
|
||||||
if (/^(hidden|collapse)$/.test(vpCs.visibility || "")) hide.set("visibility", "hidden");
|
if (/^(hidden|collapse)$/.test(vpCs.visibility || "")) hide.set("visibility", "hidden");
|
||||||
@@ -3031,6 +3047,7 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const centeredVp = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, b.vp) : false);
|
const centeredVp = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, b.vp) : false);
|
||||||
const vpDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[b.vp], b.vp, assetMap, centeredVp, colorVar, ir.doc.perViewport[b.vp]?.scrollHeight, widthPlan, gridColsByVp?.get(b.vp), gridRowsByVp?.get(b.vp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth), tokenResolver);
|
const vpDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[b.vp], b.vp, assetMap, centeredVp, colorVar, ir.doc.perViewport[b.vp]?.scrollHeight, widthPlan, gridColsByVp?.get(b.vp), gridRowsByVp?.get(b.vp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth), tokenResolver);
|
||||||
const delta = new Map<string, string>();
|
const delta = new Map<string, string>();
|
||||||
|
|||||||
@@ -100,3 +100,136 @@ describe("generateCss visibility", () => {
|
|||||||
assert.ok(!baseRule(css, "n2").includes("visibility"));
|
assert.ok(!baseRule(css, "n2").includes("visibility"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hidden-node banded geometry. A `visibility:hidden` box still PARTICIPATES in layout (unlike
|
||||||
|
// `display:none`), so the emitter must not let the base rule's baked CANONICAL geometry stand at
|
||||||
|
// widths where the capture measured something else — that is how a desktop `left:548px` slider
|
||||||
|
// arrow ends up parked, invisibly, 210px past the right edge of a 375px viewport.
|
||||||
|
describe("generateCss hidden-node banded geometry", () => {
|
||||||
|
const HVPS = [375, 1280, 1920];
|
||||||
|
type VpState = { cs?: StyleMap; bbox?: BBox; visible?: boolean };
|
||||||
|
function nodeAt(id: string, tag: string, byVp: Record<number, VpState>, children: IRChild[] = []): IRNode {
|
||||||
|
const computedByVp: Record<number, StyleMap> = {};
|
||||||
|
const bboxByVp: Record<number, BBox> = {};
|
||||||
|
const visibleByVp: Record<number, boolean> = {};
|
||||||
|
for (const vp of HVPS) {
|
||||||
|
const s = byVp[vp] ?? {};
|
||||||
|
computedByVp[vp] = computed(s.cs);
|
||||||
|
bboxByVp[vp] = s.bbox ?? { x: 0, y: 0, width: vp, height: 100 };
|
||||||
|
visibleByVp[vp] = s.visible ?? true;
|
||||||
|
}
|
||||||
|
return { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||||
|
}
|
||||||
|
function ir3(root: IRNode): IR {
|
||||||
|
const ir = irWith(root);
|
||||||
|
ir.doc.viewports = HVPS;
|
||||||
|
ir.doc.sampleViewports = HVPS;
|
||||||
|
ir.doc.perViewport = Object.fromEntries(HVPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }]));
|
||||||
|
return ir;
|
||||||
|
}
|
||||||
|
/** The `.c<id>{…}` body inside the first @media block whose query matches `mediaRe`. */
|
||||||
|
function bandRule(css: string, mediaRe: RegExp, id: string): string {
|
||||||
|
for (const m of css.matchAll(/@media ([^{]+) \{\n([\s\S]*?)\n\}/g)) {
|
||||||
|
if (!mediaRe.test(m[1]!)) continue;
|
||||||
|
const r = m[2]!.match(new RegExp(`\\.c${id}\\{([^}]*)\\}`));
|
||||||
|
if (r) return r[1]!;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const arrowCs = (left: string, hidden: boolean): StyleMap =>
|
||||||
|
({ display: "flex", position: "absolute", left, ...(hidden ? { visibility: "hidden" } : {}) });
|
||||||
|
|
||||||
|
it("emits the captured per-band geometry for a box its own visibility:hidden leaves occupying space", () => {
|
||||||
|
// Hidden at base AND at the mobile band with DIFFERENT lefts (the swiper-arrow shape): the
|
||||||
|
// band must carry the mobile left, not inherit the baked canonical one.
|
||||||
|
const arrow = nodeAt("n1", "div", {
|
||||||
|
375: { cs: arrowCs("37px", true), bbox: { x: 37, y: 0, width: 46, height: 46 }, visible: false },
|
||||||
|
1280: { cs: arrowCs("548px", true), bbox: { x: 548, y: 0, width: 46, height: 46 }, visible: false },
|
||||||
|
1920: { cs: arrowCs("588px", false), bbox: { x: 588, y: 0, width: 46, height: 46 } },
|
||||||
|
});
|
||||||
|
const root = nodeAt("n0", "body", { 1280: { cs: { position: "relative" } } }, [arrow]);
|
||||||
|
const css = generateCss(ir3(root), new Map());
|
||||||
|
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
|
||||||
|
assert.ok(baseRule(css, "n1").includes("left:548px"));
|
||||||
|
const mobile = bandRule(css, /max-width/, "n1");
|
||||||
|
assert.ok(mobile.includes("left:37px"), `mobile band should carry the captured left, got: ${mobile}`);
|
||||||
|
assert.ok(!mobile.includes("display:none"), "an occupying hidden box must stay in layout");
|
||||||
|
// The wide band where the node becomes visible keeps working: visibility restored + its left.
|
||||||
|
const wide = bandRule(css, /min-width/, "n1");
|
||||||
|
assert.ok(wide.includes("visibility:inherit"), `wide band should restore visibility, got: ${wide}`);
|
||||||
|
assert.ok(wide.includes("left:588px"), `wide band should carry the 1920 left, got: ${wide}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides a visibility:hidden box whose captured bbox is 0x0 with display:none at that band", () => {
|
||||||
|
// At 375 the hidden arrow occupied NOTHING (uninitialised swiper) — display:none reproduces
|
||||||
|
// "renders nothing, takes no space" and cannot extend the scrollable area.
|
||||||
|
const arrow = nodeAt("n1", "div", {
|
||||||
|
375: { cs: arrowCs("calc(50% - 52px)", true), bbox: { x: 0, y: 0, width: 0, height: 0 }, visible: false },
|
||||||
|
1280: { cs: arrowCs("548px", true), bbox: { x: 548, y: 0, width: 46, height: 46 }, visible: false },
|
||||||
|
1920: { cs: arrowCs("588px", false), bbox: { x: 588, y: 0, width: 46, height: 46 } },
|
||||||
|
});
|
||||||
|
const root = nodeAt("n0", "body", { 1280: { cs: { position: "relative" } } }, [arrow]);
|
||||||
|
const css = generateCss(ir3(root), new Map());
|
||||||
|
const mobile = bandRule(css, /max-width/, "n1");
|
||||||
|
assert.ok(mobile.includes("display:none"), `0x0 hidden band should be display:none, got: ${mobile}`);
|
||||||
|
const wide = bandRule(css, /min-width/, "n1");
|
||||||
|
assert.ok(wide.includes("visibility:inherit") && wide.includes("left:588px"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits display:none at a band where the node turns display:none even when hidden at base", () => {
|
||||||
|
// The base rule bakes an OCCUPYING visibility:hidden box (canonical geometry); without the
|
||||||
|
// band that box would render at mobile widths where the source had display:none.
|
||||||
|
const wrap = nodeAt("n1", "div", {
|
||||||
|
375: { cs: { display: "none", visibility: "hidden" }, bbox: { x: 0, y: 0, width: 0, height: 0 }, visible: false },
|
||||||
|
1280: { cs: { visibility: "hidden" }, bbox: { x: 40, y: 0, width: 1200, height: 574 }, visible: false },
|
||||||
|
1920: { cs: {}, bbox: { x: 320, y: 0, width: 1280, height: 533 } },
|
||||||
|
});
|
||||||
|
const root = nodeAt("n0", "body", {}, [wrap]);
|
||||||
|
const css = generateCss(ir3(root), new Map());
|
||||||
|
assert.ok(baseRule(css, "n1").includes("visibility:hidden"));
|
||||||
|
const mobile = bandRule(css, /max-width/, "n1");
|
||||||
|
assert.ok(mobile.includes("display:none"), `own display:none band must emit even when hidden at base, got: ${mobile}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits per-band geometry for an occupying box an ancestor hides at base AND at the band", () => {
|
||||||
|
// The cropin.com/cotton slider arrow: the elementor-widget ANCESTOR is visibility:hidden at
|
||||||
|
// 375/1280 (so the arrow is never ownHidden and never shownAtBase) yet the arrow's absolute
|
||||||
|
// box still occupies layout. Without a band the base's canonical left:548px parks it 210px
|
||||||
|
// past the right edge of a 375px viewport — the band must carry the captured mobile left.
|
||||||
|
const arrow = nodeAt("n2", "div", {
|
||||||
|
375: { cs: arrowCs("120px", true), bbox: { x: 112, y: 0, width: 46, height: 46 }, visible: false },
|
||||||
|
1280: { cs: arrowCs("548px", true), bbox: { x: 548, y: 0, width: 46, height: 46 }, visible: false },
|
||||||
|
1920: { cs: arrowCs("588px", false), bbox: { x: 588, y: 0, width: 46, height: 46 } },
|
||||||
|
});
|
||||||
|
const parent = nodeAt("n1", "div", {
|
||||||
|
375: { cs: { position: "relative", visibility: "hidden" }, visible: false },
|
||||||
|
1280: { cs: { position: "relative", visibility: "hidden" }, visible: false },
|
||||||
|
1920: { cs: { position: "relative" } },
|
||||||
|
}, [arrow]);
|
||||||
|
const root = nodeAt("n0", "body", {}, [parent]);
|
||||||
|
const css = generateCss(ir3(root), new Map());
|
||||||
|
const mobile = bandRule(css, /max-width/, "n2");
|
||||||
|
assert.ok(mobile.includes("left:120px"), `mobile band should carry the captured left, got: ${mobile}`);
|
||||||
|
assert.ok(!mobile.includes("display:none"), "an occupying hidden box must stay in layout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still emits only the hide for a box an ANCESTOR's visibility:hidden covers", () => {
|
||||||
|
// Inherited hides stay breakpoint noise: the ancestor's own rule (and its geometry
|
||||||
|
// correction) covers the subtree — the child emits its hide, not geometry overrides.
|
||||||
|
const child = nodeAt("n2", "div", {
|
||||||
|
375: { cs: { position: "absolute", left: "10px", visibility: "hidden" }, bbox: { x: 10, y: 0, width: 40, height: 40 }, visible: false },
|
||||||
|
1280: { cs: { position: "absolute", left: "500px" }, bbox: { x: 500, y: 0, width: 40, height: 40 } },
|
||||||
|
1920: { cs: { position: "absolute", left: "500px" }, bbox: { x: 500, y: 0, width: 40, height: 40 } },
|
||||||
|
});
|
||||||
|
const parent = nodeAt("n1", "div", {
|
||||||
|
375: { cs: { position: "relative", visibility: "hidden" }, visible: false },
|
||||||
|
1280: { cs: { position: "relative" } },
|
||||||
|
1920: { cs: { position: "relative" } },
|
||||||
|
}, [child]);
|
||||||
|
const root = nodeAt("n0", "body", {}, [parent]);
|
||||||
|
const css = generateCss(ir3(root), new Map());
|
||||||
|
const mobile = bandRule(css, /max-width/, "n2");
|
||||||
|
assert.ok(mobile.includes("visibility:hidden"), `child should carry the hide, got: ${mobile}`);
|
||||||
|
assert.ok(!mobile.includes("left:10px"), `ancestor-hidden child must not emit geometry, got: ${mobile}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createServer, type Server } from "node:http";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { captureSite, looksLikeVideoFile, type CaptureResult } from "../src/capture/capture.js";
|
||||||
|
|
||||||
|
// A minimal-but-valid mp4 shape: [size]"ftyp" brand box followed by payload bytes.
|
||||||
|
const FULL_MP4 = Buffer.concat([
|
||||||
|
Buffer.from([0x00, 0x00, 0x00, 0x18]),
|
||||||
|
Buffer.from("ftypisom"),
|
||||||
|
Buffer.from([0x00, 0x00, 0x02, 0x00]),
|
||||||
|
Buffer.from("isomiso2"),
|
||||||
|
Buffer.alloc(64_000, 0x07),
|
||||||
|
]);
|
||||||
|
// The pathological range fragment observed in the wild: a tail slice (moov atom region)
|
||||||
|
// of the file — starts mid-container, no ftyp/EBML magic.
|
||||||
|
const TAIL_FRAGMENT = FULL_MP4.subarray(FULL_MP4.length - 1000);
|
||||||
|
|
||||||
|
describe("looksLikeVideoFile (container magic)", () => {
|
||||||
|
it("accepts mp4-family (ftyp), webm/mkv (EBML), and ogg (OggS) heads", () => {
|
||||||
|
assert.equal(looksLikeVideoFile(FULL_MP4), true);
|
||||||
|
assert.equal(looksLikeVideoFile(Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(100)])), true);
|
||||||
|
assert.equal(looksLikeVideoFile(Buffer.concat([Buffer.from("OggS"), Buffer.alloc(100)])), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects range fragments, HTML error bodies, and tiny buffers", () => {
|
||||||
|
assert.equal(looksLikeVideoFile(TAIL_FRAGMENT), false, "moov-tail fragment is not a video file");
|
||||||
|
assert.equal(looksLikeVideoFile(Buffer.from("<!doctype html><html><body>404</body></html>")), false);
|
||||||
|
assert.equal(looksLikeVideoFile(Buffer.from([0x00, 0x00])), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A <video> element makes the browser fetch with Range headers; the 206 fragment must
|
||||||
|
// NOT be stored as the asset (first-stored-wins would then also block the full-download
|
||||||
|
// fallback). The fallback pass fetches without Range and stores the complete 200 body.
|
||||||
|
describe("206 range responses are not stored as full assets (integration)", () => {
|
||||||
|
let server: Server;
|
||||||
|
let url = "";
|
||||||
|
let videoUrl = "";
|
||||||
|
let outDir = "";
|
||||||
|
let capture: CaptureResult;
|
||||||
|
let rangeHits = 0;
|
||||||
|
let fullHits = 0;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = createServer((req, res) => {
|
||||||
|
if (req.url?.startsWith("/video.mp4")) {
|
||||||
|
if (req.headers.range) {
|
||||||
|
rangeHits++;
|
||||||
|
// Serve the tail fragment regardless of the requested range — the corrupt-body
|
||||||
|
// case (byte-verified in the wild: an 18,925-byte moov tail of a 6MB mp4).
|
||||||
|
res.writeHead(206, {
|
||||||
|
"content-type": "video/mp4",
|
||||||
|
"content-range": `bytes ${FULL_MP4.length - TAIL_FRAGMENT.length}-${FULL_MP4.length - 1}/${FULL_MP4.length}`,
|
||||||
|
"content-length": String(TAIL_FRAGMENT.length),
|
||||||
|
});
|
||||||
|
res.end(TAIL_FRAGMENT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fullHits++;
|
||||||
|
res.writeHead(200, { "content-type": "video/mp4", "content-length": String(FULL_MP4.length) });
|
||||||
|
res.end(FULL_MP4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "content-type": "text/html" });
|
||||||
|
res.end('<!doctype html><html><body><h1>Video page</h1><video src="/video.mp4" muted preload="auto" width="320" height="180"></video></body></html>');
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||||
|
url = `http://127.0.0.1:${(server.address() as { port: number }).port}/`;
|
||||||
|
videoUrl = url + "video.mp4";
|
||||||
|
outDir = mkdtempSync(join(tmpdir(), "ditto-206-"));
|
||||||
|
capture = await captureSite({
|
||||||
|
url,
|
||||||
|
outDir,
|
||||||
|
viewports: [800],
|
||||||
|
breakpoints: false,
|
||||||
|
screenshots: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
server?.close();
|
||||||
|
rmSync(outDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exercised both paths: browser range fetch (206) and the full fallback fetch (200)", () => {
|
||||||
|
assert.ok(rangeHits >= 1, `browser issued a Range request (got ${rangeHits})`);
|
||||||
|
assert.ok(fullHits >= 1, `fallback pass fetched the full body (got ${fullHits})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the COMPLETE 200 body, byte-identical, never the 206 fragment", () => {
|
||||||
|
const asset = capture.assets.find((a) => a.url === videoUrl);
|
||||||
|
assert.ok(asset, "video asset discovered");
|
||||||
|
assert.ok(asset!.storedAs, "video asset stored");
|
||||||
|
assert.equal(asset!.bytes, FULL_MP4.length, "stored size is the full file, not the fragment");
|
||||||
|
const stored = readFileSync(join(outDir, "assets-store", asset!.storedAs!));
|
||||||
|
assert.ok(stored.equals(FULL_MP4), "stored bytes are the complete video");
|
||||||
|
assert.ok(looksLikeVideoFile(stored), "stored file passes the container-magic check");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, it, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createServer, type Server } from "node:http";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { PageSnapshot, RawNode, RawChild } from "../src/capture/walker.js";
|
||||||
|
import { captureSite, type CaptureResult } from "../src/capture/capture.js";
|
||||||
|
import { buildIR } from "../src/normalize/ir.js";
|
||||||
|
import { buildMotionSpec, motionWireJsx, DITTO_MOTION_TSX } from "../src/generate/motion.js";
|
||||||
|
import { readJSON } from "../src/util/fsx.js";
|
||||||
|
|
||||||
|
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");
|
||||||
|
const VIEWPORTS = [375, 800];
|
||||||
|
|
||||||
|
function isText(c: RawChild): c is { text: string } {
|
||||||
|
return (c as { text?: string }).text !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findById(n: RawNode, id: string): RawNode | null {
|
||||||
|
if (n.attrs.id === id) return n;
|
||||||
|
for (const c of n.children) {
|
||||||
|
if (isText(c)) continue;
|
||||||
|
const hit = findById(c, id);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll reveals (Elementor/WOW/AOS pattern): content wrappers are `visibility:hidden`
|
||||||
|
// at load and revealed by an IntersectionObserver class swap applying entrance keyframes.
|
||||||
|
// The capture must (a) settle every reveal BEFORE any viewport snapshot so the clone never
|
||||||
|
// bakes hidden content, and (b) record the reveal as a motion spec so the clone re-hides
|
||||||
|
// and replays the entrance on scroll.
|
||||||
|
describe("scroll-reveal settling + replay capture (integration)", () => {
|
||||||
|
let server: Server;
|
||||||
|
let url = "";
|
||||||
|
let outDir = "";
|
||||||
|
let capture: CaptureResult;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const html = readFileSync(join(FIXTURES, "reveal.html"), "utf8");
|
||||||
|
server = createServer((_req, res) => {
|
||||||
|
res.writeHead(200, { "content-type": "text/html" });
|
||||||
|
res.end(html);
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||||
|
url = `http://127.0.0.1:${(server.address() as { port: number }).port}/`;
|
||||||
|
outDir = mkdtempSync(join(tmpdir(), "ditto-reveal-"));
|
||||||
|
capture = await captureSite({
|
||||||
|
url,
|
||||||
|
outDir,
|
||||||
|
viewports: VIEWPORTS,
|
||||||
|
motion: true,
|
||||||
|
breakpoints: false,
|
||||||
|
screenshots: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
server?.close();
|
||||||
|
rmSync(outDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records the POST-REVEAL steady state at every viewport snapshot", () => {
|
||||||
|
for (const vp of VIEWPORTS) {
|
||||||
|
const snap = readJSON<PageSnapshot>(join(outDir, "capture", `dom-${vp}.json`));
|
||||||
|
for (const id of ["reveal-io", "reveal-far"]) {
|
||||||
|
const node = findById(snap.root, id)!;
|
||||||
|
assert.ok(node, `#${id} captured at ${vp}`);
|
||||||
|
assert.equal(node.visible, true, `#${id} visible at ${vp}`);
|
||||||
|
assert.notEqual(node.computed.visibility, "hidden", `#${id} not baked hidden at ${vp}`);
|
||||||
|
assert.equal(node.computed.animationName, "fadeInUp", `#${id} carries the library's revealed animation at ${vp}`);
|
||||||
|
assert.ok(!node.attrs.class?.includes("elementor-invisible"), `#${id} pre-reveal marker cleared at ${vp}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never settles at a mid-fade frame (opacity is full after the entrance completes)", () => {
|
||||||
|
for (const vp of VIEWPORTS) {
|
||||||
|
const snap = readJSON<PageSnapshot>(join(outDir, "capture", `dom-${vp}.json`));
|
||||||
|
for (const id of ["reveal-io", "reveal-far"]) {
|
||||||
|
const node = findById(snap.root, id)!;
|
||||||
|
assert.equal(parseFloat(node.computed.opacity ?? "1"), 1, `#${id} opacity settled at ${vp}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves genuinely hidden non-library content hidden (fidelity)", () => {
|
||||||
|
for (const vp of VIEWPORTS) {
|
||||||
|
const snap = readJSON<PageSnapshot>(join(outDir, "capture", `dom-${vp}.json`));
|
||||||
|
const never = findById(snap.root, "never")!;
|
||||||
|
assert.ok(never, `#never captured at ${vp}`);
|
||||||
|
assert.equal(never.visible, false, `#never stays hidden at ${vp}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures both reveals as visibility-family motion specs with the entrance animation", () => {
|
||||||
|
const reveals = capture.motion?.reveals ?? [];
|
||||||
|
assert.equal(reveals.length, 2, `exactly the two reveal roots (got ${JSON.stringify(reveals)})`);
|
||||||
|
for (const rv of reveals) {
|
||||||
|
assert.equal(rv.visibility, "hidden");
|
||||||
|
assert.equal(rv.animationName, "fadeInUp");
|
||||||
|
assert.equal(rv.animationDuration, "0.4s");
|
||||||
|
assert.equal(rv.animationDelay, "0s");
|
||||||
|
assert.ok(rv.animationTiming, "timing function recorded");
|
||||||
|
assert.equal(rv.transition, "", "visibility family carries no transition");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("threads the reveal specs into the generated motion spec (re-hide + replay)", () => {
|
||||||
|
const ir = buildIR(outDir, VIEWPORTS);
|
||||||
|
const spec = buildMotionSpec(ir, capture.motion);
|
||||||
|
assert.equal(spec.reveals.length, 2, "both reveals resolve to surviving cids");
|
||||||
|
for (const rv of spec.reveals) {
|
||||||
|
assert.ok(rv.cid, "reveal mapped to a rendered cid");
|
||||||
|
assert.equal(rv.visibility, "hidden");
|
||||||
|
assert.equal(rv.animationName, "fadeInUp");
|
||||||
|
}
|
||||||
|
const jsx = motionWireJsx(spec, 0);
|
||||||
|
assert.match(jsx, /<DittoMotion spec=/);
|
||||||
|
assert.match(jsx, /"animationName":"fadeInUp"/);
|
||||||
|
assert.match(jsx, /"visibility":"hidden"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DittoMotion re-hides via JS-applied visibility and replays the captured @keyframes", () => {
|
||||||
|
// Initial hide is JS-applied (SSR/non-JS still shows content), entrance restarted on view.
|
||||||
|
assert.match(DITTO_MOTION_TSX, /el\.style\.visibility = "hidden"/);
|
||||||
|
assert.match(DITTO_MOTION_TSX, /el\.style\.animationName = rv\.animationName/);
|
||||||
|
assert.match(DITTO_MOTION_TSX, /el\.style\.visibility = "visible"/);
|
||||||
|
// Validator settle path reveals WITHOUT replaying (no mid-entrance graded frames).
|
||||||
|
assert.match(DITTO_MOTION_TSX, /f\(false\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { declToUtil } from "../src/generate/tailwind.js";
|
||||||
|
|
||||||
|
// Zero-value gating. A named `-0` step only exists for props on Tailwind's spacing (or numeric)
|
||||||
|
// scales; for the rest the class compiles to NOTHING — a silent no-op that ships the wrong style
|
||||||
|
// (a map label captured at font-size:0 painted at the inherited 20px because `text-0` isn't a
|
||||||
|
// utility). Zeros for scale-less props must stay arbitrary so the declaration really compiles.
|
||||||
|
describe("declToUtil zero values", () => {
|
||||||
|
it("emits arbitrary text-[0px] for font-size:0 (no text-0 utility in v4)", () => {
|
||||||
|
assert.equal(declToUtil("font-size", "0px"), "text-[0px]");
|
||||||
|
assert.equal(declToUtil("font-size", "0"), "text-[0px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits arbitrary tracking-[0px] for letter-spacing:0 (no tracking-0 utility in v4)", () => {
|
||||||
|
assert.equal(declToUtil("letter-spacing", "0px"), "tracking-[0px]");
|
||||||
|
assert.equal(declToUtil("letter-spacing", "0"), "tracking-[0px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits arbitrary rounded corners for radius:0 (radius scale has no numeric 0)", () => {
|
||||||
|
assert.equal(declToUtil("border-top-left-radius", "0px"), "rounded-tl-[0px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the named -0 for spacing-scale props", () => {
|
||||||
|
assert.equal(declToUtil("width", "0px"), "w-0");
|
||||||
|
assert.equal(declToUtil("width", "0"), "w-0");
|
||||||
|
assert.equal(declToUtil("top", "0px"), "top-0");
|
||||||
|
assert.equal(declToUtil("margin-left", "0px"), "ml-0");
|
||||||
|
assert.equal(declToUtil("line-height", "0px"), "leading-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the named -0 for numeric scales that include 0", () => {
|
||||||
|
assert.equal(declToUtil("flex-grow", "0"), "grow-0");
|
||||||
|
assert.equal(declToUtil("flex-shrink", "0"), "shrink-0");
|
||||||
|
assert.equal(declToUtil("order", "0"), "order-0");
|
||||||
|
assert.equal(declToUtil("z-index", "0"), "z-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves non-zero values untouched", () => {
|
||||||
|
assert.equal(declToUtil("font-size", "20px"), "text-[20px]");
|
||||||
|
assert.equal(declToUtil("letter-spacing", "-0.5px"), "tracking-[-0.5px]");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user