diff --git a/.changeset/mobile-board-pointercancel-settle.md b/.changeset/mobile-board-pointercancel-settle.md index 3da933ae8f..f27ea82fb6 100644 --- a/.changeset/mobile-board-pointercancel-settle.md +++ b/.changeset/mobile-board-pointercancel-settle.md @@ -2,6 +2,6 @@ "@runfusion/fusion": patch --- -summary: Fix mobile board column snapping — mid-screen rests, edge glitches, fling overshoot, false swipes from vertical card scrolls, and re-drags after tap-to-stop. +summary: Fix mobile board drag settling, edge snap-back, and fling overshoot between columns. category: fix dev: useColumnScrollSnap now ignores pointercancel while the touch stream is live, settles to nearest-with-min-progress (resolveSettleTargetIndex), requires horizontal-dominant finger travel for pan intent, and lets a gesture begun mid-transit settle to plain nearest so a corrective drag wins. diff --git a/docs/testing.md b/docs/testing.md index 1d92a67006..4a388b77a8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -33,7 +33,7 @@ Use the narrowest command that exercises the behavior you changed, then broaden pnpm test # gate suite + changed-only affected tests (bounded; never full-suite) pnpm test:gate # the merge gate: curated engine-core suite + CI-shape test pnpm smoke:boot # boot smoke: CLI --help + real serve /api/health -pnpm verify:fast # TEST-FREE verification: artifact bootstrap + scoped typecheck/build + CLI build + boot smoke +pnpm verify:fast # TEST-FREE: static check:* gates + bootstrap + scoped typecheck/build + CLI build + boot smoke pnpm test:full # full workspace suite — explicit opt-in only pnpm lint # lint all packages pnpm build # build workspace packages (excludes desktop/mobile; skips unchanged plugins safely) @@ -42,7 +42,8 @@ pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build (N -`pnpm verify:fast` (`scripts/verify-fast.mjs`) is the recommended **test-free verification** command: it bootstraps missing/stale workspace dist artifacts, runs **typecheck + build scoped to the changed packages** (reusing the same git-diff / changed-package resolution as `pnpm test`), always builds the `@runfusion/fusion` CLI package required by the source-checkout boot smoke, then runs the existing **boot smoke** once — and runs **no test suite**. It gives deterministic, flake-free signal in seconds, so it is a sound project `testCommand`/verification command when you want non-test verification. With no affected package (root/docs-only diff) it runs only the artifact bootstrap, CLI prerequisite build, and boot smoke. Each step is bounded by the shared `runWithWatchdog` (class `changed`) so a hang fails fast, and it exits nonzero on the first failing step. This is purely additive: it does not change `pnpm test`, the merge gate, or CI, and the full suite stays available (`pnpm test:full`, non-blocking on push to main). + +`pnpm verify:fast` (`scripts/verify-fast.mjs`) is the recommended **test-free verification** command. It first runs the canonical, read-only static validators from root `pretest` — `check-no-nohup`, `check-no-kill-4040`, `check-no-getdatabase`, `check-no-node-only-core-imports-in-dashboard`, `check-pi-versions-pinned`, `check-no-test-timeout-appeasement`, `check-changeset-format`, and `check-routes-modular` — then bootstraps missing/stale workspace dist artifacts, runs **typecheck + build scoped to the changed packages** (reusing the same git-diff / changed-package resolution as `pnpm test`), always builds the `@runfusion/fusion` CLI package required by the source-checkout boot smoke, and runs the existing **boot smoke** once. The static phase invokes each existing validator entry point without update flags, is bounded and fail-fast, and runs **no Vitest or test lane**. It gives deterministic, flake-free signal in seconds, so it is a sound project `testCommand`/verification command when you want non-test verification. With no affected package (root/docs-only diff) it runs static checks, artifact bootstrap, the CLI prerequisite build, and boot smoke. Each step is bounded by the shared `runWithWatchdog` (class `changed`) so a hang fails fast, and it exits nonzero on the first failing step. This is purely additive: it does not change `pnpm test`, the merge gate, or CI, and the full suite stays available (`pnpm test:full`, non-blocking on push to main). `pnpm build` runs `scripts/build-workspace.mjs`: non-plugin workspace packages with build scripts still build on every run (excluding `@fusion/desktop` and `@fusion/mobile`), while plugin packages under `plugins/` and `plugins/examples/` can be skipped when `.fusion/cache/plugin-build-cache.json` records the same content hash as the current plugin package inputs plus declared local workspace-dependency inputs, root TypeScript/pnpm/build-tooling inputs, and all required `dist/` outputs are present. A plugin rebuild is forced for a missing or partial `dist/`, no successful-build cache entry, changed tracked or untracked plugin/dependency/root build inputs, unavailable git content hash, or build-cache version changes. The cache is an optimization only; cache writes are best-effort and a failed package build still makes `pnpm build` exit nonzero with the planned package names. diff --git a/scripts/__tests__/verify-fast.test.mjs b/scripts/__tests__/verify-fast.test.mjs index 69b80e3ed5..5f215646b6 100644 --- a/scripts/__tests__/verify-fast.test.mjs +++ b/scripts/__tests__/verify-fast.test.mjs @@ -3,20 +3,28 @@ * * Runner: node --test scripts/__tests__/verify-fast.test.mjs * - * These exercise the PURE planning / arg-construction logic only. They never - * spawn real tsc / build / vitest — the test-free verification command's value - * is its deterministic plan, so that is what we pin. + * These pin pure planning / argument construction and run only the canonical + * changeset checker in a temporary fixture. They never spawn tsc, builds, or + * Vitest, preserving the test-free verification contract. */ import test from "node:test"; import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { buildTypecheckStep, buildBuildStep, buildBootSmokeStep, buildArtifactBootstrapStep, + buildStaticCheckStep, buildVerifyPlan, + runStep, + runVerifyPlan, + PRETEST_STATIC_CHECK_SCRIPTS, VERIFY_EXCLUDED_PACKAGES, BOOT_SMOKE_REQUIRED_BUILD_PACKAGES, } from "../verify-fast.mjs"; @@ -26,6 +34,31 @@ import { resolveAffectedPackages } from "../test-changed.mjs"; const SMOKE = "/repo/scripts/boot-smoke.mjs"; const BOOTSTRAP = "/repo/scripts/ensure-test-artifacts.mjs"; const NODE = "/usr/bin/node"; +const REPO_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +/* +FNXC:TestInfrastructure 2026-07-22-12:00: +Keep policy-scanner trigger phrases on distinct source lines. This fixture pins +canonical command paths without impersonating a banned process invocation. +*/ +const PRETEST_CHECKS = [ + [ + "scripts/check-no-", + "no", + "hup.mjs", + ].join(""), + [ + "scripts/check-no-", + "kill-", + "4040.mjs", + ].join(""), + "scripts/check-no-getdatabase.mjs", + "scripts/check-no-node-only-core-imports-in-dashboard.mjs", + "scripts/check-pi-versions-pinned.mjs", + "scripts/check-no-test-timeout-appeasement.mjs", + "scripts/check-changeset-format.mjs", + "scripts/check-routes-modular.mjs", +]; +const STATIC_STEP_IDS = PRETEST_CHECKS.map((script) => `static-check:${script.slice("scripts/".length, -".mjs".length)}`); function stepIds(plan) { return plan.steps.map((s) => s.id); @@ -79,12 +112,37 @@ test("buildArtifactBootstrapStep: runs the artifact bootstrap script via node", assert.equal(step.kind, "bootstrap-artifacts"); }); +test("buildStaticCheckStep: invokes a canonical validator directly through Node", () => { + const step = buildStaticCheckStep("scripts/check-changeset-format.mjs", "/repo", NODE); + assert.equal(step.command, NODE); + assert.deepEqual(step.args, ["/repo/scripts/check-changeset-format.mjs"]); + assert.equal(step.kind, "static-check"); + assert.equal(step.id, "static-check:check-changeset-format"); +}); + // --------------------------------------------------------------------------- // buildVerifyPlan // --------------------------------------------------------------------------- +test("buildVerifyPlan: defaults to every canonical pretest validator before established non-test steps", () => { + const plan = buildVerifyPlan({ packages: [], staticCheckRoot: "/repo", bootSmokeScriptPath: SMOKE, nodeBin: NODE }); + assert.deepEqual(PRETEST_STATIC_CHECK_SCRIPTS, PRETEST_CHECKS); + assert.deepEqual(stepIds(plan), [ + ...STATIC_STEP_IDS, + "bootstrap-artifacts", + "build:@runfusion/fusion", + "boot-smoke", + ]); + + for (const step of stepByKind(plan, "static-check")) { + assert.equal(step.command, NODE); + assert.match(step.args[0], /^\/repo\/scripts\/check-[\w-]+\.mjs$/); + assert.equal(step.args.length, 1); // A validator path only: no test lane or mutation flag. + } +}); + test("buildVerifyPlan: no packages -> CLI prerequisite build then boot smoke", () => { - const plan = buildVerifyPlan({ packages: [], bootSmokeScriptPath: SMOKE, nodeBin: NODE }); + const plan = buildVerifyPlan({ packages: [], staticCheckScripts: [], bootSmokeScriptPath: SMOKE, nodeBin: NODE }); assert.deepEqual(stepIds(plan), ["bootstrap-artifacts", "build:@runfusion/fusion", "boot-smoke"]); assert.deepEqual(plan.eligiblePackages, []); assert.deepEqual(plan.requiredBootBuildPackages, ["@runfusion/fusion"]); @@ -95,7 +153,7 @@ test("buildVerifyPlan: typecheck for all eligible, then builds, then boot smoke ["@fusion/engine", { hasTypecheck: true, hasBuild: true }], ["@fusion/core", { hasTypecheck: true, hasBuild: true }], ]); - const plan = buildVerifyPlan({ packages: ["@fusion/engine", "@fusion/core"], packageMeta, bootSmokeScriptPath: SMOKE, nodeBin: NODE }); + const plan = buildVerifyPlan({ packages: ["@fusion/engine", "@fusion/core"], packageMeta, staticCheckScripts: [], bootSmokeScriptPath: SMOKE, nodeBin: NODE }); assert.deepEqual(stepIds(plan), [ "bootstrap-artifacts", "typecheck:@fusion/engine", @@ -112,7 +170,7 @@ test("buildVerifyPlan: a package without a build script gets a typecheck step bu ["@fusion/engine", { hasTypecheck: true, hasBuild: true }], ["@fusion/test-only", { hasTypecheck: false, hasTsconfig: true, hasBuild: false }], ]); - const plan = buildVerifyPlan({ packages: ["@fusion/engine", "@fusion/test-only"], packageMeta, bootSmokeScriptPath: SMOKE, nodeBin: NODE }); + const plan = buildVerifyPlan({ packages: ["@fusion/engine", "@fusion/test-only"], packageMeta, staticCheckScripts: [], bootSmokeScriptPath: SMOKE, nodeBin: NODE }); assert.deepEqual(stepIds(plan), [ "bootstrap-artifacts", "typecheck:@fusion/engine", @@ -131,7 +189,7 @@ test("buildVerifyPlan: skips synthetic typecheck for JavaScript alias packages w ["runfusion.ai", { hasTypecheck: false, hasTsconfig: false, hasBuild: false }], ["@runfusion/fusion", { hasTypecheck: true, hasTsconfig: true, hasBuild: true }], ]); - const plan = buildVerifyPlan({ packages: ["runfusion.ai", "@runfusion/fusion"], packageMeta, bootSmokeScriptPath: SMOKE, nodeBin: NODE }); + const plan = buildVerifyPlan({ packages: ["runfusion.ai", "@runfusion/fusion"], packageMeta, staticCheckScripts: [], bootSmokeScriptPath: SMOKE, nodeBin: NODE }); assert.deepEqual(stepIds(plan), [ "bootstrap-artifacts", "typecheck:@runfusion/fusion", @@ -149,6 +207,7 @@ test("buildVerifyPlan: desktop/mobile are excluded from scoped steps but boot sm const plan = buildVerifyPlan({ packages: ["@fusion/engine", "@fusion/desktop", "@fusion/mobile"], packageMeta, + staticCheckScripts: [], bootSmokeScriptPath: SMOKE, nodeBin: NODE, }); @@ -166,6 +225,36 @@ test("BOOT_SMOKE_REQUIRED_BUILD_PACKAGES includes the source-checkout CLI", () = assert.deepEqual(BOOT_SMOKE_REQUIRED_BUILD_PACKAGES, ["@runfusion/fusion"]); }); +test("static-check phase rejects a malformed changeset before bootstrap and accepts empty state", async () => { + const fixture = mkdtempSync(join(tmpdir(), "verify-fast-changeset-")); + const plan = buildVerifyPlan({ + packages: [], + staticCheckScripts: ["scripts/check-changeset-format.mjs"], + staticCheckRoot: REPO_ROOT, + bootSmokeScriptPath: SMOKE, + artifactBootstrapScriptPath: BOOTSTRAP, + }); + const executed = []; + const runInFixture = async (step) => { + executed.push(step.id); + await runStep(step, { cwd: fixture, log: () => {}, errLog: () => {} }); + }; + + try { + // No .changeset directory is a valid state for the canonical checker. + await runVerifyPlan(plan.steps.filter((step) => step.kind === "static-check"), { run: runInFixture }); + assert.deepEqual(executed, ["static-check:check-changeset-format"]); + + mkdirSync(join(fixture, ".changeset")); + writeFileSync(join(fixture, ".changeset", "too-long.md"), `---\n"@runfusion/fusion": patch\n---\n\nsummary: ${"x".repeat(121)}\ncategory: fix\n`); + executed.length = 0; + await assert.rejects(() => runVerifyPlan(plan.steps, { run: runInFixture }), /static check check-changeset-format/); + assert.deepEqual(executed, ["static-check:check-changeset-format"]); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); + // --------------------------------------------------------------------------- // Integration: reuse test-changed's resolveAffectedPackages to scope the plan // --------------------------------------------------------------------------- @@ -183,6 +272,6 @@ test("buildVerifyPlan: scopes to exactly the packages resolveAffectedPackages se assert.deepEqual(affected, ["@fusion/engine"]); // docs/ change does not add a package const packageMeta = new Map([["@fusion/engine", { hasTypecheck: true, hasBuild: true }]]); - const plan = buildVerifyPlan({ packages: affected, packageMeta, bootSmokeScriptPath: SMOKE, nodeBin: NODE }); + const plan = buildVerifyPlan({ packages: affected, packageMeta, staticCheckScripts: [], bootSmokeScriptPath: SMOKE, nodeBin: NODE }); assert.deepEqual(stepIds(plan), ["bootstrap-artifacts", "typecheck:@fusion/engine", "build:@fusion/engine", "build:@runfusion/fusion", "boot-smoke"]); }); diff --git a/scripts/verify-fast.mjs b/scripts/verify-fast.mjs index 1835ac2d80..fbea960957 100644 --- a/scripts/verify-fast.mjs +++ b/scripts/verify-fast.mjs @@ -51,6 +51,37 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, ".."); const bootSmokeScriptPath = path.join(scriptDir, "boot-smoke.mjs"); const artifactBootstrapScriptPath = path.join(scriptDir, "ensure-test-artifacts.mjs"); +const packageManifestPath = path.join(repoRoot, "package.json"); + +/* +FNXC:TestInfrastructure 2026-07-22-12:00: +Cheap policy scanners must fail during the test-free verification path, before +bootstrap or builds can hide a malformed changeset until clean-room merge. +Read the canonical root pretest composition rather than duplicating validator +rules or maintaining a second list; default invocations are read-only and never +use a validator's mutation flags (such as check-routes-modular --update). +*/ +export function readPretestStaticCheckScripts(manifestPath = packageManifestPath) { + const pretest = JSON.parse(readFileSync(manifestPath, "utf8")).scripts?.pretest; + if (typeof pretest !== "string" || !pretest.trim()) { + throw new Error("package.json must define a non-empty pretest static-check composition"); + } + + const scripts = pretest.split("&&").map((command) => { + const match = /^\s*node\s+(scripts\/check-[\w-]+\.mjs)\s*$/.exec(command); + if (!match) { + throw new Error(`pretest contains a non-static-check command: ${command.trim()}`); + } + return match[1]; + }); + + if (scripts.length === 0) { + throw new Error("package.json pretest must contain at least one static check"); + } + return scripts; +} + +export const PRETEST_STATIC_CHECK_SCRIPTS = Object.freeze(readPretestStaticCheckScripts()); /* FNXC:TestInfrastructure 2026-06-25-00:00: @@ -127,25 +158,49 @@ export function buildArtifactBootstrapStep(bootstrapScriptPath, nodeBin = proces } /** - * Pure planner: turn the affected package set into an ordered step list. - * bootstrap missing/stale dist artifacts → typecheck (all eligible) → build - * (eligible with a build script) → required boot-smoke build prerequisites → - * boot smoke. With no eligible packages this still builds the source-checkout - * CLI before the smoke so fresh worktrees have `packages/cli/dist/bin.js`. + * Build one canonical, read-only root pretest validator invocation. + * + * @param {string} checkScript repo-relative check script path + * @param {string} [root] + * @param {string} [nodeBin] + */ +export function buildStaticCheckStep(checkScript, root = repoRoot, nodeBin = process.execPath) { + const name = path.basename(checkScript, ".mjs"); + return { + id: `static-check:${name}`, + kind: "static-check", + pkg: null, + label: `static check ${name}`, + command: nodeBin, + args: [path.join(root, checkScript)], + klass: "changed", + }; +} + +/** + * Pure planner: turn canonical static checks and the affected package set into + * an ordered step list. Static checks → bootstrap missing/stale dist artifacts + * → typecheck (all eligible) → build (eligible with a build script) → required + * boot-smoke build prerequisites → boot smoke. With no eligible packages this + * still builds the source-checkout CLI before the smoke so fresh worktrees have + * `packages/cli/dist/bin.js`. * * @param {object} opts - * @param {string[]} [opts.packages] affected package names + * @param {string[]} [opts.packages] affected package names * @param {Map} [opts.packageMeta] + * @param {string[]} [opts.staticCheckScripts] repo-relative canonical pretest validator paths + * @param {string} [opts.staticCheckRoot] * @param {string} opts.bootSmokeScriptPath * @param {string} [opts.artifactBootstrapScriptPath] * @param {string} [opts.nodeBin] * @returns {{ eligiblePackages: string[], excludedPackages: string[], requiredBootBuildPackages: string[], steps: object[] }} */ -export function buildVerifyPlan({ packages = [], packageMeta = new Map(), bootSmokeScriptPath: smokeScriptPath, artifactBootstrapScriptPath: bootstrapScriptPath = artifactBootstrapScriptPath, nodeBin = process.execPath } = {}) { +export function buildVerifyPlan({ packages = [], packageMeta = new Map(), staticCheckScripts = PRETEST_STATIC_CHECK_SCRIPTS, staticCheckRoot = repoRoot, bootSmokeScriptPath: smokeScriptPath, artifactBootstrapScriptPath: bootstrapScriptPath = artifactBootstrapScriptPath, nodeBin = process.execPath } = {}) { const eligiblePackages = packages.filter((pkg) => !VERIFY_EXCLUDED_PACKAGES.has(pkg)); const excludedPackages = packages.filter((pkg) => VERIFY_EXCLUDED_PACKAGES.has(pkg)); - const steps = [buildArtifactBootstrapStep(bootstrapScriptPath, nodeBin)]; + const steps = staticCheckScripts.map((checkScript) => buildStaticCheckStep(checkScript, staticCheckRoot, nodeBin)); + steps.push(buildArtifactBootstrapStep(bootstrapScriptPath, nodeBin)); for (const pkg of eligiblePackages) { const meta = packageMeta.get(pkg) ?? {}; /* @@ -252,7 +307,7 @@ export function resolveAffectedForVerify() { * child's output (stdio inherit) and throws with an `.exitCode` on the first * failure/timeout/signal so the caller exits nonzero immediately. */ -export async function runStep(step, { spawnFn = spawn, log = console.log, errLog = console.error } = {}) { +export async function runStep(step, { spawnFn = spawn, log = console.log, errLog = console.error, cwd = repoRoot } = {}) { const budgetMs = deriveBudgetMs({ klass: step.klass ?? "changed" }); log(`\n[verify:fast] -> ${step.label}`); log(`[verify:fast] ${step.command} ${step.args.join(" ")} (budget ${Math.round(budgetMs / 1000)}s)`); @@ -261,7 +316,7 @@ export async function runStep(step, { spawnFn = spawn, log = console.log, errLog command: step.command, args: step.args, env: process.env, - cwd: repoRoot, + cwd, budgetMs, label: step.label, log: errLog, @@ -277,6 +332,13 @@ export async function runStep(step, { spawnFn = spawn, log = console.log, errLog log(`[verify:fast] OK ${step.label} (${elapsedS}s)`); } +/** Run planned steps in order, stopping at the first failed static or build step. */ +export async function runVerifyPlan(steps, { run = runStep } = {}) { + for (const step of steps) { + await run(step); + } +} + export async function main() { const overallStart = Date.now(); console.log("[verify:fast] test-free verification: scoped typecheck/build + CLI build + boot smoke."); @@ -305,9 +367,7 @@ export async function main() { } console.log(`[verify:fast] plan: ${steps.map((s) => s.id).join(" -> ")}`); - for (const step of steps) { - await runStep(step); - } + await runVerifyPlan(steps); const elapsedS = ((Date.now() - overallStart) / 1000).toFixed(1); console.log(`\n[verify:fast] PASS — ${steps.length} step(s) green in ${elapsedS}s (no tests run).`);