From ffe53b784cdc7eadc8bd99486a734ab83940fe52 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Wed, 12 Aug 2026 10:06:29 +0500 Subject: [PATCH 1/4] fix(builder): include iframe padding in the frame's content origin An iframe's nested viewport begins at the CONTENT box, so padding displaces it exactly as a border does. Callers built the inset from clientLeft/clientTop, which report the border alone, so every frame-local point mapped toward the border by the scaled padding. Measured in Chromium: an 8px border with 12px padding puts the border box at x=8 and the inner element at host x=28, while border-only arithmetic computes 16. The 12px gap is the padding exactly. The bordered acceptance case could not expose it because it set only a border; it now sets padding too and asserts both applied, since a case that silently loses its inline style degrades to the at-rest case and passes. Also anchors the discovery check outside the derived glob. The .mts control proves the runner follows that extension by executing, but a glob set that loses .mts stops collecting the control, so the evidence vanishes with the behaviour and the run just reports fewer tests. The assertion now lives in a .ts file, which any glob set that collects anything will collect. --- e2e/tests/canvas/coordinate-mapping.spec.ts | 50 +++++++++++++-------- e2e/tests/canvas/poc-driver.ts | 23 ++++++---- packages/builder/src/geometry.ts | 16 ++++--- packages/builder/src/layering.test.ts | 27 ++++++++++- packages/builder/src/source-modules.ts | 2 +- 5 files changed, 85 insertions(+), 33 deletions(-) diff --git a/e2e/tests/canvas/coordinate-mapping.spec.ts b/e2e/tests/canvas/coordinate-mapping.spec.ts index 8c5b5b1443..057c355ae7 100644 --- a/e2e/tests/canvas/coordinate-mapping.spec.ts +++ b/e2e/tests/canvas/coordinate-mapping.spec.ts @@ -77,8 +77,16 @@ async function mapped( // The scale is passed through rather than measured so that `mapped(page, 1)` // stays a coherent "what a naive implementation computes" — it gets the wrong // origin AND the wrong mapping, which is what the control below asserts. + // Border AND padding: the nested viewport begins at the content box, so both + // displace it. `clientLeft` alone is the reading that looks complete. const inset = await frameElement.evaluate( - el => ({ left: el.clientLeft, top: el.clientTop }) + el => { + const style = getComputedStyle(el); + return { + left: el.clientLeft + parseFloat(style.paddingLeft || "0"), + top: el.clientTop + parseFloat(style.paddingTop || "0"), + }; + } ); const contentOrigin = @@ -207,7 +215,7 @@ test("point 5: the mapping survives scroll and scale together", async ({ expect(delta).toBeLessThanOrEqual(1); }); -test("point 5: the mapping survives a bordered frame under scale", async ({ +test("point 5: the mapping survives a bordered, padded frame under scale", async ({ page, request, }) => { @@ -219,23 +227,29 @@ test("point 5: the mapping survives a bordered frame under scale", async ({ // where the content origin and the border-box corner are the same point. That // makes them all agree whether or not the inset is scaled — so none of them // can see this, and the fault would ship on the first bordered canvas. - await page - .locator("iframe") - .evaluate( - (el: HTMLIFrameElement) => (el.style.border = "8px solid transparent") - ); + // + // Padding as well as a border, because the nested viewport begins at the + // CONTENT box: padding displaces it exactly as a border does, and a case + // setting only a border passes whether or not padding is accounted for. + await page.locator("iframe").evaluate((el: HTMLIFrameElement) => { + el.style.border = "8px solid transparent"; + el.style.padding = "12px"; + }); await driver.setZoom(0.5); // Precondition, not decoration. If the canvas stylesheet wins over the inline - // border, `clientLeft` is 0, this silently becomes the at-rest case, and it + // style, the inset is 0, this silently becomes the at-rest case, and it // passes while testing nothing at all. - const inset = await page + const applied = await page .locator("iframe") - .evaluate((el: HTMLIFrameElement) => el.clientLeft); + .evaluate((el: HTMLIFrameElement) => ({ + border: el.clientLeft, + padding: parseFloat(getComputedStyle(el).paddingLeft || "0"), + })); expect( - inset, - "the border must actually apply for this to test anything" - ).toBe(8); + applied, + "the border and padding must actually apply for this to test anything" + ).toEqual({ border: 8, padding: 12 }); const scaled = worstDelta(await mapped(page, 0.5), await groundTruth(page)); const raw = worstDelta( @@ -244,13 +258,13 @@ test("point 5: the mapping survives a bordered frame under scale", async ({ ); test.info().annotations.push({ - type: "delta-bordered-scaled", - description: `scaled=${scaled} raw=${raw} inset=${inset}`, + type: "delta-bordered-padded-scaled", + description: `scaled=${scaled} raw=${raw} border=${applied.border} padding=${applied.padding}`, }); - // Both halves, for the same reason as the scale test. The first says scaling - // the inset is right; the second says it MATTERS — an 8px border at 50% puts - // the raw sum 4px out, so a regression to it cannot pass this quietly. + // Both halves, for the same reason as the scale test. The first says the + // full inset scaled is right; the second says it MATTERS — 20px of inset at + // 50% puts the raw sum 10px out, so a regression cannot pass this quietly. expect(scaled).toBeLessThanOrEqual(1); expect(raw).toBeGreaterThan(1); }); diff --git a/e2e/tests/canvas/poc-driver.ts b/e2e/tests/canvas/poc-driver.ts index d01646e6b4..5d1399ebb6 100644 --- a/e2e/tests/canvas/poc-driver.ts +++ b/e2e/tests/canvas/poc-driver.ts @@ -184,14 +184,21 @@ export function createPocDriver(page: Page): CanvasDriver { // border has that gap from the first render, and it reads as "the // indicator feels slightly off" rather than as a fault. // - // Measured here, converted there. `clientLeft` is in the frame's own - // untransformed pixels while the box is post-transform, so the two cannot - // be added without the scale, and doing that sum at the call site is what - // put the same error in two files. - const inset = await frame.evaluate(el => ({ - left: el.clientLeft, - top: el.clientTop, - })); + // Border AND padding. The nested viewport begins at the CONTENT box, so + // padding displaces it exactly as a border does — and `clientLeft` reports + // only the border, which is the reading that looks complete and is not. + // + // Measured here, converted there. These are the frame's own untransformed + // pixels while the box is post-transform, so the two cannot be added + // without the scale, and doing that sum at the call site is what put the + // same error in several files. + const inset = await frame.evaluate(el => { + const style = getComputedStyle(el); + return { + left: el.clientLeft + parseFloat(style.paddingLeft || "0"), + top: el.clientTop + parseFloat(style.paddingTop || "0"), + }; + }); return frameContentOrigin(box, inset, await frameScale()); }, diff --git a/packages/builder/src/geometry.ts b/packages/builder/src/geometry.ts index e8b5debff1..7d9610125b 100644 --- a/packages/builder/src/geometry.ts +++ b/packages/builder/src/geometry.ts @@ -117,10 +117,16 @@ function assertUsable(frame: FrameGeometry): void { /** * How far the frame's content viewport sits inside its border box. * - * `clientLeft` and `clientTop` exactly as the DOM reports them, which is the - * whole reason this type exists rather than the caller passing two numbers: they - * are CSS pixels in the FRAME's own untransformed space. Every other coordinate - * in this module is host space. Mixing the two is the mistake below. + * The COMPLETE offset, border plus padding. A border alone is the tempting + * reading, because `clientLeft`/`clientTop` report exactly that and are the + * obvious things to reach for — but an iframe's nested viewport begins at the + * content box, so padding displaces it too. Measured in Chromium: an 8px border + * with 12px padding puts the content 20px in, while `clientLeft` reports 8. + * + * In CSS pixels of the FRAME's own untransformed space, which is the whole + * reason this type exists rather than the caller passing two numbers. Every + * other coordinate in this module is host space, and mixing the two is the + * mistake {@link frameContentOrigin} exists to prevent. */ export interface FrameInset { readonly left: number; @@ -131,7 +137,7 @@ export interface FrameInset { * Where the frame's content viewport starts, in host coordinates. * * `borderBox` is the corner `getBoundingClientRect` reports for the frame - * element, and `inset` is its border width. The border is laid out in the + * element, and `inset` is its border-to-content offset. That offset is laid out in the * frame's own pixels, so a host that has scaled the frame scales the border with * everything else: at 50% a 2px border occupies 1 host pixel. Adding the inset * unscaled therefore misplaces the origin by `(1 - scale) * inset`, which is diff --git a/packages/builder/src/layering.test.ts b/packages/builder/src/layering.test.ts index 81b89dd74c..759184f878 100644 --- a/packages/builder/src/layering.test.ts +++ b/packages/builder/src/layering.test.ts @@ -4,7 +4,12 @@ import { fileURLToPath } from "node:url"; import ts from "typescript"; import { describe, expect, it } from "vitest"; -import { collectModules, TEST_MODULE } from "./source-modules"; +import { + collectModules, + MODULE_EXTENSIONS, + TEST_GLOBS, + TEST_MODULE, +} from "./source-modules"; /** * The package's layering contract, enforced rather than documented. @@ -474,6 +479,26 @@ describe("the builder's layering contract", () => { expect(files.some(f => f.endsWith("index.ts"))).toBe(true); }); + it("asks the runner to collect every extension it treats as a test", () => { + // Anchored HERE, in a `.ts` file, and not in the `.mts` control itself. + // That control proves the runner follows `.mts` by executing — but if the + // globs lose that extension the control stops being collected, so the + // evidence disappears with the behaviour it was evidence for, and the run + // simply reports fewer tests passing. + // + // This file is collected by any glob set that collects anything, so it + // survives to report the narrowing. + expect(TEST_GLOBS).toHaveLength(MODULE_EXTENSIONS.length); + for (const extension of MODULE_EXTENSIONS) { + expect(TEST_GLOBS).toContain(`src/**/*.test.${extension}`); + } + // And every glob names a file this package would classify as a test, so the + // runner and the allowlist cannot mean different things by the word. + for (const glob of TEST_GLOBS) { + expect(TEST_MODULE.test(glob)).toBe(true); + } + }); + it("reads every extension it claims to, not only the common ones", () => { // `length > 0` and "an index.ts is present" both survive a walk narrowed to // `.ts` alone, so neither separates full coverage from partial. A file in a diff --git a/packages/builder/src/source-modules.ts b/packages/builder/src/source-modules.ts index cc196104a6..8d35c1e8e1 100644 --- a/packages/builder/src/source-modules.ts +++ b/packages/builder/src/source-modules.ts @@ -40,7 +40,7 @@ * with either extension is a module the bundler follows, so a list omitting * them leaves that file invisible to every check in this package. */ -const MODULE_EXTENSIONS = [ +export const MODULE_EXTENSIONS = [ "ts", "tsx", "mts", From 36e57110bd9c1bf296f2639ff78ad6fa4c0773c0 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Wed, 12 Aug 2026 12:02:00 +0500 Subject: [PATCH 2/4] fix(builder): measure the frame inset in one place, and correct every recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The padding fix duplicated the very thing it was fixing: clientLeft + padding was written independently in the acceptance mapping and in the driver, so a future correction to one would recreate the silent drift. frameInsetOf is now the single reader, exported from the package. It is passed INTO the page rather than called on the Node side — Playwright serializes the function, which works only because it closes over nothing — so one definition serves the editor and the harness alike. Both public recipes corrected too. The README and the FrameGeometry JSDoc still defined the inset as clientLeft/clientTop, so a consumer following either instruction reproduced the offset this branch fixes. They now name the function instead of restating a recipe, because the recipe was documented and three call sites still got it wrong. --- e2e/tests/canvas/coordinate-mapping.spec.ts | 14 +++---- e2e/tests/canvas/coordinate-mapping.ts | 3 +- e2e/tests/canvas/poc-driver.ts | 16 +++---- packages/builder/README.md | 18 +++++--- packages/builder/src/geometry-dom.ts | 46 +++++++++++++++++++++ packages/builder/src/geometry.ts | 8 ++-- packages/builder/src/index.ts | 9 ++++ 7 files changed, 88 insertions(+), 26 deletions(-) create mode 100644 packages/builder/src/geometry-dom.ts diff --git a/e2e/tests/canvas/coordinate-mapping.spec.ts b/e2e/tests/canvas/coordinate-mapping.spec.ts index 057c355ae7..1a3b4e97c5 100644 --- a/e2e/tests/canvas/coordinate-mapping.spec.ts +++ b/e2e/tests/canvas/coordinate-mapping.spec.ts @@ -18,6 +18,7 @@ import { expect, test } from "@playwright/test"; import { FLAT_LIST_FIXTURE, seedPage } from "./fixtures"; import { frameContentOrigin, + frameInsetOf, mapFramePointToHost, mapFrameRectToHost, mapHostPointToFrame, @@ -77,16 +78,11 @@ async function mapped( // The scale is passed through rather than measured so that `mapped(page, 1)` // stays a coherent "what a naive implementation computes" — it gets the wrong // origin AND the wrong mapping, which is what the control below asserts. - // Border AND padding: the nested viewport begins at the content box, so both - // displace it. `clientLeft` alone is the reading that looks complete. + // The same shared reader the driver uses, passed into the page. Border AND + // padding: the nested viewport begins at the content box, so both displace + // it, and `clientLeft` alone is the reading that looks complete. const inset = await frameElement.evaluate( - el => { - const style = getComputedStyle(el); - return { - left: el.clientLeft + parseFloat(style.paddingLeft || "0"), - top: el.clientTop + parseFloat(style.paddingTop || "0"), - }; - } + frameInsetOf ); const contentOrigin = diff --git a/e2e/tests/canvas/coordinate-mapping.ts b/e2e/tests/canvas/coordinate-mapping.ts index 63fa602519..538aea977c 100644 --- a/e2e/tests/canvas/coordinate-mapping.ts +++ b/e2e/tests/canvas/coordinate-mapping.ts @@ -23,6 +23,7 @@ */ import { frameContentOrigin, + frameInsetOf, pointToCanvas, pointToHost, rectToHost, @@ -78,4 +79,4 @@ export function mapFrameRectToHost( * site is how one of them ends up adding the inset unscaled. The measurement * stays in the driver; the sums stay in the editor's module. */ -export { frameContentOrigin, type FrameInset }; +export { frameContentOrigin, frameInsetOf, type FrameInset }; diff --git a/e2e/tests/canvas/poc-driver.ts b/e2e/tests/canvas/poc-driver.ts index 5d1399ebb6..d693e6bd77 100644 --- a/e2e/tests/canvas/poc-driver.ts +++ b/e2e/tests/canvas/poc-driver.ts @@ -9,6 +9,7 @@ import { gotoAdmin } from "../support/admin"; import { frameContentOrigin, + frameInsetOf, mapFramePointToHost, mapFrameRectToHost, type FrameInset, @@ -192,13 +193,14 @@ export function createPocDriver(page: Page): CanvasDriver { // pixels while the box is post-transform, so the two cannot be added // without the scale, and doing that sum at the call site is what put the // same error in several files. - const inset = await frame.evaluate(el => { - const style = getComputedStyle(el); - return { - left: el.clientLeft + parseFloat(style.paddingLeft || "0"), - top: el.clientTop + parseFloat(style.paddingTop || "0"), - }; - }); + // The shared reader, PASSED INTO the page rather than called here. + // `evaluate` serializes the function and runs it in the browser, so this + // works only because `frameInsetOf` closes over nothing — it reads the + // element and globals and no module scope. That is what lets one + // definition serve both the editor and this harness. + const inset = await frame.evaluate( + frameInsetOf + ); return frameContentOrigin(box, inset, await frameScale()); }, diff --git a/packages/builder/README.md b/packages/builder/README.md index 2d51d810b6..6b38689e84 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -90,12 +90,18 @@ Scroll inside the frame is deliberately not a field: a rectangle read from inside is already relative to the frame's viewport, so subtracting its scroll would count it twice. -`frameContentOrigin(borderBox, inset, scale)` — build that origin from what the -DOM reports. `getBoundingClientRect` gives the BORDER box while the inset -(`clientLeft`/`clientTop`) is in the frame's own untransformed pixels, so the -inset has to be scaled before it is added. Getting that wrong misplaces every -overlay by `(1 - scale) * inset`, which is zero at 100% and therefore invisible -in the state a canvas is developed in. +`frameInsetOf(iframe)` — measure the inset. Border AND padding, because an +iframe's nested viewport begins at the CONTENT box: `clientLeft`/`clientTop` +report only the border, and a padded frame displaces the viewport further. +Provided as a function rather than as a recipe because the recipe was +documented and three call sites still got it wrong. + +`frameContentOrigin(borderBox, inset, scale)` — build the origin from that +inset and the frame's measured box. `getBoundingClientRect` gives the BORDER +box while the inset is in the frame's own untransformed pixels, so the inset +has to be scaled before it is added. Getting that wrong misplaces every overlay +by `(1 - scale) * inset`, which is zero at 100% and therefore invisible in the +state a canvas is developed in. `pointToHost` / `pointToCanvas` — a point across the frame, in either direction. Exact inverses rather than two mappings written to match, because diff --git a/packages/builder/src/geometry-dom.ts b/packages/builder/src/geometry-dom.ts new file mode 100644 index 0000000000..bd6fae3f63 --- /dev/null +++ b/packages/builder/src/geometry-dom.ts @@ -0,0 +1,46 @@ +/** + * The one place a frame's inset is READ from the DOM. + * + * `geometry.ts` deliberately takes plain numbers so the mapping can be + * exercised without a browser. That leaves one question it cannot answer, and + * it is the question every caller gets wrong: which measurements add up to the + * offset between a frame's border box and its content viewport. + * + * Answering it in prose did not work. The contract was documented as + * `clientLeft`/`clientTop`, three call sites followed that recipe, and all + * three were wrong by the padding — because an iframe's nested viewport begins + * at the CONTENT box, so padding displaces it exactly as a border does. A + * recipe a caller applies is a recipe a caller can misapply; a function they + * call is not. + * + * Kept in its own module rather than folded into `geometry.ts` so that the + * arithmetic stays testable without a DOM. This is the edge; that is the pure + * part. + * + * @module geometry-dom + */ + +import type { FrameInset } from "./geometry"; + +/** + * How far a frame's content viewport sits inside its border box. + * + * Border plus padding, in the frame's own untransformed CSS pixels — which is + * what {@link FrameInset} means and what `frameContentOrigin` scales. + * + * `clientLeft`/`clientTop` report the border alone. Padding comes from the + * computed style because there is no element property that carries it, and a + * non-pixel value (a percentage, `auto`) resolves to pixels there too. + */ +export function frameInsetOf(frame: HTMLIFrameElement): FrameInset { + const style = frame.ownerDocument.defaultView?.getComputedStyle(frame); + // A frame detached from its document has no view and therefore no computed + // padding. Its border is still readable, so report what is knowable rather + // than guessing a padding that would silently displace every mapped point. + const paddingLeft = style === undefined ? 0 : parseFloat(style.paddingLeft); + const paddingTop = style === undefined ? 0 : parseFloat(style.paddingTop); + return { + left: frame.clientLeft + (Number.isFinite(paddingLeft) ? paddingLeft : 0), + top: frame.clientTop + (Number.isFinite(paddingTop) ? paddingTop : 0), + }; +} diff --git a/packages/builder/src/geometry.ts b/packages/builder/src/geometry.ts index 7d9610125b..f2b3334d7f 100644 --- a/packages/builder/src/geometry.ts +++ b/packages/builder/src/geometry.ts @@ -56,9 +56,11 @@ export interface Rect { * `origin` is where the frame's CONTENT viewport lands in host coordinates, and * the word content is load-bearing. `getBoundingClientRect` on an iframe reports * its BORDER box, while every rectangle read inside the frame is relative to the - * content viewport — so on a frame with any border the two differ by - * `clientLeft`/`clientTop`, and an overlay built from the border box sits a - * couple of scaled pixels out at every point. A canvas that never sets + * content viewport — so on a frame with any border or padding the two differ, + * and an overlay built from the border box sits a couple of scaled pixels out + * at every point. Measure that difference with {@link frameInsetOf}; the inset + * is border PLUS padding, and `clientLeft`/`clientTop` alone are short by the + * padding. A canvas that never sets * `border: none` gets the browser default and the fault is present from the * first render, which is exactly the sort of near-miss that reads as "the * indicator feels slightly off" rather than as a bug. diff --git a/packages/builder/src/index.ts b/packages/builder/src/index.ts index cc4ebf8445..144c8c89ed 100644 --- a/packages/builder/src/index.ts +++ b/packages/builder/src/index.ts @@ -62,3 +62,12 @@ export { type Point, type Rect, } from "./geometry"; + +/** + * The DOM read the mapping cannot do for itself. + * + * Exported beside the geometry because the inset is the one input every caller + * has to measure, and the one they get wrong: documenting the recipe as + * `clientLeft`/`clientTop` left three call sites short by the padding. + */ +export { frameInsetOf } from "./geometry-dom"; From f403f7fde488692ce39e599bd0edf3e9a19c48f9 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Wed, 12 Aug 2026 12:26:31 +0500 Subject: [PATCH 3/4] test(builder): check test discovery from outside the globs being checked Deriving the vitest globs, the layering guard's idea of a test and the file walk from one extension list means a single edit narrows all of them at once. Dropping "ts" from MODULE_EXTENSIONS leaves one .mts control collected and the run reports 1 passed (1) in green, with every layering and geometry guard silently absent. The previous anchor sat in layering.test.ts, reasoning that any glob set collecting anything would collect that file. The narrowing un-collects it too, which is how the mutation went green; its comment now says what it actually covers. The check moves to globalSetup, which runs before any file is collected, so no glob decides whether it executes. Both outcomes are loud: with files collected it throws, and with none collected vitest raises FilesNotFoundError itself. It reads neither BUNDLED_MODULE nor MODULE_EXTENSIONS, because both narrow with the mutation and the check would agree with itself and pass. It walks every file, spells out what a test is named like, and compares that against the globs the runner was handed. An empty walk throws rather than reporting success. --- packages/builder/src/layering.test.ts | 15 ++-- packages/builder/vitest.config.ts | 8 ++ packages/builder/vitest.global-setup.ts | 100 ++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 packages/builder/vitest.global-setup.ts diff --git a/packages/builder/src/layering.test.ts b/packages/builder/src/layering.test.ts index 759184f878..1ef03a7d4e 100644 --- a/packages/builder/src/layering.test.ts +++ b/packages/builder/src/layering.test.ts @@ -480,14 +480,15 @@ describe("the builder's layering contract", () => { }); it("asks the runner to collect every extension it treats as a test", () => { - // Anchored HERE, in a `.ts` file, and not in the `.mts` control itself. - // That control proves the runner follows `.mts` by executing — but if the - // globs lose that extension the control stops being collected, so the - // evidence disappears with the behaviour it was evidence for, and the run - // simply reports fewer tests passing. + // The globs and the allowlist agree about the word "test". That is an + // internal-consistency property and it is worth checking here, but it is + // NOT what catches a narrowed extension list: this assertion lives in a + // file the globs decide whether to collect, so dropping `ts` un-collects + // the check along with everything else and the run reports `1 passed (1)` + // in green. Measured, not supposed. // - // This file is collected by any glob set that collects anything, so it - // survives to report the narrowing. + // What survives that is in `vitest.global-setup.ts`, which runs before any + // file is collected and compares the globs against the tests on disk. expect(TEST_GLOBS).toHaveLength(MODULE_EXTENSIONS.length); for (const extension of MODULE_EXTENSIONS) { expect(TEST_GLOBS).toContain(`src/**/*.test.${extension}`); diff --git a/packages/builder/vitest.config.ts b/packages/builder/vitest.config.ts index c3b7af7b85..d846b8ae55 100644 --- a/packages/builder/vitest.config.ts +++ b/packages/builder/vitest.config.ts @@ -16,11 +16,19 @@ import { TEST_GLOBS } from "./src/source-modules"; * and the guard have to mean the same thing by the word: a hand-written glob * that omitted an extension the guard accepted would let a file import `vitest` * and never be run. Both now come from one list in `src/source-modules.ts`. + * + * `globalSetup` is what keeps that derivation honest. Narrowing the one list + * narrows these globs too, and a suite that stops being collected reports the + * same green as a suite that passed — so the check that the runner still + * collects every test on disk cannot itself be a test, because the narrowing + * would un-collect it. It runs before collection instead, where no glob decides + * whether it executes. */ export default defineConfig({ test: { environment: "node", include: TEST_GLOBS, + globalSetup: ["./vitest.global-setup.ts"], }, }); diff --git a/packages/builder/vitest.global-setup.ts b/packages/builder/vitest.global-setup.ts new file mode 100644 index 0000000000..035ff363d2 --- /dev/null +++ b/packages/builder/vitest.global-setup.ts @@ -0,0 +1,100 @@ +/** + * That the runner still collects every test on disk, checked from outside the + * globs being checked. + * + * This package derives vitest's `include` from one extension list so the globs, + * the layering guard's idea of a test, and the file walk cannot drift apart. + * The cost of deriving them together is that a single edit narrows all of them + * at once, and a suite that stops being collected looks exactly like a suite + * that passed. + * + * That is measured, not hypothetical. Dropping `ts` from `MODULE_EXTENSIONS` + * leaves one `.mts` control collected and the run reports `1 passed (1)` in + * green, with every layering and geometry guard silently absent. The previous + * attempt to catch this put the assertion in `layering.test.ts`, reasoning that + * any glob set collecting anything would collect that file. The mutation + * un-collects it too, which is precisely how the mutation went green. + * + * A check only survives a mutation it does not depend on. `globalSetup` runs + * before any test file is collected, so no glob decides whether it executes, + * and both outcomes are loud: with files collected this throws, and with none + * collected vitest raises `FilesNotFoundError` by itself. + * + * So the rule below reads NEITHER `BUNDLED_MODULE` nor `MODULE_EXTENSIONS`. + * Both narrow with the mutation — a walk filtered by `BUNDLED_MODULE` stops + * returning `.ts` files at the same moment the globs stop matching them, and + * the check would agree with itself and pass. It walks every file, spells out + * what a test is named like, and compares that against the globs the runner was + * actually handed. + */ +import { readdirSync } from "node:fs"; +import { dirname, join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { TEST_GLOBS } from "./src/source-modules"; + +// `import.meta.dirname` only exists from Node 20.11 and the package floor is +// lower, matching how the suite's own guards resolve this directory. +const SRC_DIR = join(dirname(fileURLToPath(import.meta.url)), "src"); + +/** + * What a test file is named like, written out rather than derived. + * + * The literal is the whole point. Deriving this from `MODULE_EXTENSIONS` would + * make it narrow in lockstep with the thing it is checking, which is the defect + * rather than the fix. + */ +const LOOKS_LIKE_A_TEST = /\.test\.[^.]+$/; + +/** Every file beneath a directory, with no opinion about extensions. */ +function everyFile(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...everyFile(full)); + else out.push(full); + } + return out; +} + +/** + * The trailing text a glob matches: `src/**\/*.test.ts` yields `.test.ts`. + * + * Read off the value handed to `include` rather than rebuilt from the list it + * came from, so a glob shape that stopped matching real files is visible here + * instead of being assumed away. + */ +function globSuffix(glob: string): string { + return glob.slice(glob.lastIndexOf("*") + 1); +} + +export default function assertTheRunnerCollectsEveryTest(): void { + const onDisk = everyFile(SRC_DIR).filter(file => + LOOKS_LIKE_A_TEST.test(file) + ); + + // Positive control. An empty walk satisfies the check below while proving + // nothing, and passing because it found nothing is the exact failure this + // file exists to end. + if (onDisk.length === 0) { + throw new Error( + `No test files were found under ${SRC_DIR}, so this check cannot ` + + `confirm the runner collects them and refuses to report success.` + ); + } + + const suffixes = TEST_GLOBS.map(globSuffix); + const uncollected = onDisk + .filter(file => !suffixes.some(suffix => file.endsWith(suffix))) + .map(file => relative(SRC_DIR, file).split(sep).join("/")); + + if (uncollected.length > 0) { + throw new Error( + `${uncollected.length} test file(s) exist that no vitest glob collects. ` + + `They would be skipped and the run would still report success:\n` + + uncollected.map(name => ` src/${name}`).join("\n") + + `\n\nThe runner was given: ${TEST_GLOBS.join(", ")}\n` + + `Add the missing extension to MODULE_EXTENSIONS in src/source-modules.ts.` + ); + } +} From 7ddeb239b785c1df594f8e922a859cfa222c46fe Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Wed, 12 Aug 2026 12:28:06 +0500 Subject: [PATCH 4/4] chore(builder): add the lockstep changeset for the frame inset fix The branch changes runtime behaviour in a published package and adds a public export, so it needs a release record of its own to schedule the train. --- .changeset/frame-content-origin-padding.md | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/frame-content-origin-padding.md diff --git a/.changeset/frame-content-origin-padding.md b/.changeset/frame-content-origin-padding.md new file mode 100644 index 0000000000..8d6f123c12 --- /dev/null +++ b/.changeset/frame-content-origin-padding.md @@ -0,0 +1,29 @@ +--- +"nextly": patch +"create-nextly-app": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/ui": patch +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/builder": patch +--- + +Correct the frame content origin to include the iframe's padding, and measure that inset in one place. + +An iframe's nested viewport begins at the content box, so padding displaces it exactly as a border does. Callers built the inset from `clientLeft`/`clientTop`, which report the border alone, so every frame-local point mapped toward the border by the scaled padding. `frameInsetOf` is now exported as the single reader, and both the README recipe and the `FrameGeometry` documentation name it instead of restating arithmetic three call sites had already got wrong.