From 16050e18b50000866b2595c6eea3d91b0978a312 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 03:08:50 +0000 Subject: [PATCH 1/2] test(ci): a `dist` vitest project so built-artifact claims can be pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root `vitest.config.mts` aliases every workspace package to its `src`, which is right for the ~2000 tests that want fast source feedback and leaves "does the SHIPPED BUNDLE still do X" unanswerable. Such a test could not be committed: turbo's `test` task is `dependsOn: ["^build"]` — the DEPENDENCIES' builds, not the package's own — so a `dist`-importing test landed in CI with no `dist` to import. That is NOT MEASURED rather than a red pin, and the usual repair (delete it, or let it skip when `dist` is missing) leaves a green suite that measures nothing. Two lanes hit this wall in one morning and each threw a correct measurement away. Implements the PM ruling on objectui#7183 (option 1, 2026-09-02): - `vitest.config.mts` gains a fourth project, `dist`, collecting `packages/*/src/**/*.dist.spec.tsx`. The suffix keeps these files out of `unit`, `dom` and `dom-heavy` BY CONSTRUCTION — none of the three needed a character changed — and out of each package's `tsconfig.test.json`, which matters because turbo's `type-check` waits on `^build` and must never read the package's own `dist` (objectui#4801). - The project is OPT-IN behind `OBJECTUI_DIST_PINS=1`. CI's test job runs `pnpm test` with no build step, so an unconditional fourth project would be collected there with no `dist` on disk and would fail every PR. The one false-green this opens — `--project dist` without the env var collecting zero files and exiting green — is refused in the config with a message naming the right command. - `turbo.json` gains `test:dist`, `dependsOn: ["build"]` (self, not `^build`), `cache: false`: a lane whose subject is a build artifact turbo does not hash must not replay a verdict. That places it on the uncached side of the partition `scripts/__tests__/turbo-task-guard-coverage.test.ts` enforces, whose docstring is updated to match. - The light dom setup is deliberate, not a cost optimisation: `vitest.setup.dom.tsx` registers `page:header` from SOURCE, which would keep a pin green with the built bundle removed entirely. First resident: the objectui#6252 acceptance criterion PR objectui#7180 measured by hand and could not commit — an id-authored `page:header` resolves through the BUILT renderer and carries no `body.source` into the DOM or the authored node. Its live control is part of the pin: with the `dist` import removed the run fails `expected undefined to be truthy`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b --- .changeset/7183-dist-vitest-project.md | 21 ++ .github/workflows/ci.yml | 18 ++ content/docs/guide/ci-cd-pipeline.md | 2 +- package.json | 1 + packages/components/package.json | 1 + .../page-header-action-ids.dist.spec.tsx | 217 ++++++++++++++++++ .../turbo-task-guard-coverage.test.ts | 1 + turbo.json | 4 + vitest.config.mts | 89 +++++++ 9 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 .changeset/7183-dist-vitest-project.md create mode 100644 packages/components/src/__tests__/page-header-action-ids.dist.spec.tsx diff --git a/.changeset/7183-dist-vitest-project.md b/.changeset/7183-dist-vitest-project.md new file mode 100644 index 0000000000..4d10ef7fd9 --- /dev/null +++ b/.changeset/7183-dist-vitest-project.md @@ -0,0 +1,21 @@ +--- +--- + +Test and CI infrastructure only — nothing this change touches is published, so no package +releases from it. + +Adds a fourth vitest project, `dist`, holding built-artifact pins: tests that import their +package's BUILT bundle instead of its `src`. The root config aliases every workspace package +to `src`, which is correct for the ~2000 tests that want fast source feedback and leaves +"does the shipped bundle still do X" structurally unanswerable. Until now such a test could +not be committed at all — turbo's `test` task is `dependsOn: ["^build"]`, the DEPENDENCIES' +builds and not the package's own, so a `dist`-importing test landed in CI with no `dist` to +import. That reads as NOT MEASURED rather than as a red pin, and the usual repair (delete it, +or let it skip when `dist` is missing) leaves a green suite that measures nothing. + +The one file the changeset-presence gate flags, +`packages/components/src/__tests__/page-header-action-ids.dist.spec.tsx`, is under `src/` but +ships nowhere: the package publishes `files: ["dist", …]`, and its `tsconfig.json` build +excludes `src/__tests__` outright. The rest of the change is `turbo.json`, `vitest.config.mts`, +the root and `@object-ui/components` `package.json` scripts, and one CI step — no runtime +source, no published contract, no behaviour change for any consumer. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9402516686..3a36187234 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -646,6 +646,24 @@ jobs: - name: Run tests (shard ${{ matrix.shard }}/4) if: steps.relevant.outputs.should_run == 'true' run: pnpm test --shard=${{ matrix.shard }}/4 + # The built-artifact lane (objectui#7183). The step above resolves every + # workspace package to its `src` — that is what the root alias map is for + # — so no test in it can observe its own package's BUILT output. This is + # the only place a `dist`-importing pin runs, and `turbo run test:dist` + # carries `dependsOn: ["build"]` for the package under test, so the bundle + # exists before the pin reads it. Before this step such a pin could not be + # committed at all: it landed with no `dist` to import, which is + # NOT MEASURED rather than a red pin, and the usual repair (delete it, or + # let it skip) leaves a green suite that measures nothing. + # + # Shard 1 only, and NOT sharded itself. The four shards are independent + # runners, so running this on all four would pay for the same build four + # times to measure the same bundle; the lane is a handful of files, so it + # runs whole, here. No `timeout-minutes` change and no heavy-test + # allowlist entry: the cost is one package build in one shard. + - name: Run built-artifact pins (dist project) + if: steps.relevant.outputs.should_run == 'true' && matrix.shard == 1 + run: pnpm test:dist # Push to main/develop: the coverage lane, sharded 4 ways with a blob-report # merge (objectui#5403). The comment that used to sit here declined to shard diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index aec460536c..cbde54abf3 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -207,7 +207,7 @@ it green — which is how two of `type-check`'s gates came to be missing from th |---|---|---|---| | `changeset-check` | Changeset Fixed Group Check | `scripts/check-changeset-fixed.mjs` — every workspace package must be in the changeset `fixed` group or explicitly ignored. It checks group *membership*; it does **not** check whether the PR added a changeset. | Every run | | `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:side-effects-array`, then `pnpm check:element-data-source-declaration`, then `pnpm check:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:designer-field-key-parity`, then `pnpm check:icon-record-names`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:side-effects-array` runs next, sources only and no build: it fails when a package's `sideEffects` ARRAY and its module bodies disagree in either direction — a module that registers something at load time and is not named (a bundler drops it, and the registration is gone from a *consumer's* app with no error, no warning and exit 0), or a name whose module no longer registers anything. `@object-ui/app-shell` declares such an array because both simpler answers are measurably wrong for it: omitting the field makes the whole package unshakeable, and `"sideEffects": false` silently drops three live SDUI widget registrations to zero chunks ([#6535](https://github.com/objectstack-ai/objectui/issues/6535), [#6683](https://github.com/objectstack-ai/objectui/issues/6683)). The enumeration is re-derived from the module bodies on every run rather than listed, so there is no second copy to rot. The artifact half of the same contract — do those registrations survive a real bundler — cannot run in this job at all: it needs a built console, so it lives in the SDUI registration pin step of `performance-budget.yml`. `pnpm check:element-data-source-declaration` runs next, sources only and no build: it fails when a source that consumes `ElementDataSourceGate` does not also pass through `elementDataSourceBlock()`, the seam that declares the `dataSource` key the gate reads. A block that wraps the gate off-seam publishes an authoring surface missing the one key its own runtime honours, and the html tier reports that key with the same `unknown-prop` warning it gives the spellings that do nothing ([#6678](https://github.com/objectstack-ai/objectui/issues/6678)). `pnpm check:esm-specifiers` follows it for the same reason — sources only, no build: it fails when a published package whose build preserves import specifiers (a bare emitting `tsc`, which never rewrites them) writes a relative specifier with no file extension. Node's ESM resolver does not extension-search relative specifiers, so such a specifier makes the published entry unloadable outside a bundler; `@object-ui/react`'s entry died with `ERR_MODULE_NOT_FOUND` while every bundler-based consumer, the whole test suite and CI stayed green ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)). The half that actually *imports* each built entry needs a full build and runs in `node-esm-load-gate.yml`. `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). `pnpm check:designer-field-key-parity` fails when one of the field designers' statically declared payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`, `DesignerFieldDefinition`) declares a key the installed `@objectstack/spec` `FieldSchema` refuses by NAME. Such a key makes `PUT /api/v1/meta/object/:name` return a hard 422 `INVALID_METADATA` that blocks *every subsequent save* of that object, and the author cannot tell from the designer UI which key did it — the class had been filed three times, each closed with a per-key tombstone written after the instance was found in production, with nothing detecting the next one ([#4644](https://github.com/objectstack-ai/objectui/issues/4644) `indexed`, [#4687](https://github.com/objectstack-ai/objectui/issues/4687) `distance_metric`, [#4676](https://github.com/objectstack-ai/objectui/issues/4676) `placeholder`, gated by [#5761](https://github.com/objectstack-ai/objectui/issues/5761)). It reads the accept set off the schema itself rather than from a list, and it covers a deliberately documented *subset* of the write path: a key that reaches the payload only through a `patchDef` spread or an index signature is outside its reach, and the boundary is stated in the script's own docblock. Its draft-I/O half — the `readFields`/`writeFields` round-trip, which has no declared shape to read — runs in the test suite as `object-fields-io.spec-keys.test.ts`. Same placement rationale as the gates around it: it parses the sources with `typescript` and imports the installed spec, so it needs the install and nothing built. `pnpm check:icon-record-names` fails when an authored icon NAME that reaches a resolver reading lucide's runtime `icons` record is not a live key of that record. lucide retires a spelling by dropping it from that record while keeping it as a deprecated named export, so the retired name still imports, still type-checks and still renders wherever it is used as a *component* — `Edit === SquarePen` is true — and resolves to nothing wherever it is used as a *string*: nothing goes red in either direction, which is why the class was repaired twice in two packages before anyone gated it ([#5586](https://github.com/objectstack-ai/objectui/issues/5586), [#5622](https://github.com/objectstack-ai/objectui/issues/5622), [#5633](https://github.com/objectstack-ai/objectui/issues/5633)). It carries no list of retired spellings — the record itself is the judgement — and it re-discovers the resolver population from source on every run, which is how its first pass found four record-reading resolvers nobody had catalogued. It sits here because it parses the sources with `typescript` and reads the installed lucide: the install, and nothing built. The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed | -| `test` | Test (shard N/4) | `pnpm test --shard=N/4` across a 4-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. | Pull requests and merge-queue builds (everything but `push`); steps short-circuit on a PR that changed only ignored paths | +| `test` | Test (shard N/4) | `pnpm test --shard=N/4` across a 4-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. Then, **on shard 1 only**, `pnpm test:dist` — the built-artifact lane ([#7183](https://github.com/objectstack-ai/objectui/issues/7183)). It delegates to a turbo task scoped to the one package that holds built-artifact pins; that task depends on the package's OWN build (`dependsOn: ["build"]`, not `^build`), so the bundle exists before the pins read it, and then runs the `dist` vitest project, whose pins import a package's BUILT bundle instead of its `src` — a claim the source-aliased suite above is structurally unable to make, since the root config aliases every workspace package to `src`. It is deliberately not sharded and not repeated on the other three runners: the lane is a handful of files, and running it on all four would pay for the same build four times. | Pull requests and merge-queue builds (everything but `push`); steps short-circuit on a PR that changed only ignored paths | | `test-coverage` | Test (coverage shard N/4) | `pnpm test:coverage --reporter=blob --shard=N/4` across a 4-runner matrix with `fail-fast: false`. Each shard writes `.vitest-reports/blob-N-4.json` — raw coverage and test results in one file — and uploads it as an artifact even when the shard is red, which is what makes a failing coverage run diagnosable at all (vitest deletes `coverage/` on a red run unless `coverage.reportOnFailure` is set, [#5402](https://github.com/objectstack-ai/objectui/issues/5402)). The configured coverage thresholds are neutralised on the shard legs, because a quarter of the suite judged against a whole-suite threshold is not a defect signal; they are enforced once, on the merged report, by the job below ([#5403](https://github.com/objectstack-ai/objectui/issues/5403)). | **Push only** | | `coverage-report` | Test (coverage) | Downloads the four blob reports, refuses to continue unless all four arrived, merges them with `pnpm test:coverage --merge-reports` into one complete report — which is where the configured coverage thresholds are enforced, over the whole merged map, the shard legs having overridden them to zero — and publishes that report as the `coverage-report` artifact (kept 7 days, the same as the blobs it is derived from). Its last step runs on every path and states the outcome: the job is **red, with an error annotation**, whenever the gate did not run for the commit — before [#5403](https://github.com/objectstack-ai/objectui/issues/5403) the final step carried the implicit `success()` and was silently skipped by 311 of 373 coverage jobs, which is how four days of a 100%-failing coverage job went unnoticed. A breach of the thresholds is reported *separately* from a lane that never delivered, because the two call for opposite actions. ⛔ It never merges a report from fewer than four shards: a wrong coverage number is worse than a missing one. The Codecov upload this job used to carry was retired by [#5436](https://github.com/objectstack-ai/objectui/issues/5436) — `CODECOV_TOKEN` was never set, so it failed on every push; the trend dashboard and PR coverage comments are gone with it, the gate is not. | **Push only** | | `e2e` | Build & E2E | Builds the console with `vite build` (`VITE_BASE_PATH=/console/`), verifies the artifact, then `pnpm test:e2e --project=chromium`. Uploads the Playwright report on failure. | Every run; on a PR the steps short-circuit when only ignored paths changed | diff --git a/package.json b/package.json index 2d9fa3799f..bef1402ad2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test": "vitest run", "test:unit": "vitest run --project unit", "test:integration": "vitest run --project ui", + "test:dist": "turbo run test:dist --filter=@object-ui/components", "site:dev": "pnpm --filter @object-ui/site dev", "site:build": "pnpm --filter @object-ui/site build", "site:start": "pnpm --filter @object-ui/site start", diff --git a/packages/components/package.json b/packages/components/package.json index bc5216958a..5ad8847699 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -30,6 +30,7 @@ "prebuild": "pnpm --filter @object-ui/types build && pnpm --filter @object-ui/core build && pnpm --filter @object-ui/react build", "pretest": "pnpm run prebuild", "test": "vitest run", + "test:dist": "OBJECTUI_DIST_PINS=1 vitest run --root ../.. --config vitest.config.mts --project dist", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." }, diff --git a/packages/components/src/__tests__/page-header-action-ids.dist.spec.tsx b/packages/components/src/__tests__/page-header-action-ids.dist.spec.tsx new file mode 100644 index 0000000000..c27b0a79aa --- /dev/null +++ b/packages/components/src/__tests__/page-header-action-ids.dist.spec.tsx @@ -0,0 +1,217 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * BUILT-ARTIFACT PIN — the first resident of the `dist` vitest project + * (objectui#7183, PM ruling 2026-09-02, option 1). + * + * ## What this file is, and why it is not a `*.test.tsx` + * + * Every other test in this repository reads its package's `src`: the root + * `vitest.config.mts` aliases `@object-ui/components` to + * `packages/components/src`, which is correct for the ~2000 tests that want + * fast source feedback and structurally unable to answer "does the SHIPPED + * BUNDLE still do X". This file answers exactly that class of question, and it + * is the only kind of file that belongs in the `dist` project. + * + * The `.dist.spec.tsx` suffix is load-bearing three times over, and each one is + * a hazard rather than a style choice: + * + * 1. `unit` collects `packages/**` + `*.test.ts` and `dom` collects + * `packages/**` + `*.test.tsx`. A `*.test.tsx` here would be collected by + * the light `dom` project, where `packages/components/dist` does not + * exist — the NOT MEASURED failure this card was filed about. + * 2. `packages/components/tsconfig.test.json` compiles this package's tests — + * its include names `*.test.ts` and `*.test.tsx` under `src`. (Spelled + * without the glob that pairs a star with a slash: inside a block comment + * that pair ENDS the comment, and the file then fails to parse. This file + * hit exactly that, and `tsconfig.scripts.json`'s header records the same + * trap.) + * This file must stay OUT of that program: turbo's `type-check` task is + * `dependsOn: ["^build"]` — the DEPENDENCIES' builds, never this package's + * own — so a type program that reads `../../dist/index.d.ts` would demand + * an artifact `type-check` is not allowed to wait for. objectui#4801 + * removed a self-referencing `paths` entry for precisely that reason; this + * file must not reintroduce the coupling by the back door. + * 3. `tsconfig.json` (the package build) already excludes `src/__tests__`, so + * nothing here reaches `dist`. + * + * ## The measurement (objectui#6252 acceptance criterion 3) + * + * Re-derived from PR objectui#7180, which ran it by hand and could not commit + * it: "the id path carries no `body.source` into the built artifact". An + * id-authored `page:header` resolves an action whose definition carries a + * script body; the built renderer must resolve the id, must not leak the body + * into the DOM, and must not write the resolved definition back onto the + * authored node (that node is what a page build serializes). + * + * ## The live control — read this before trusting a green + * + * `registers page:header from the BUILT bundle` is the control, and it is the + * reason a green here means anything. Delete the `await import(...)` line below + * and that case fails with `expected undefined to be truthy`: `page:header` is + * registered by NOTHING else in this project, because the `dist` project runs + * the LIGHT dom setup, which deliberately imports none of the + * `@object-ui/components` graph. So a passing run has measured the built + * bundle and nothing else — there is no source path that could have satisfied + * it. + * + * That is also why this project must never adopt `vitest.setup.dom.tsx`: that + * setup registers `page:header` from SOURCE, which would keep every assertion + * below green with the built bundle removed entirely. + */ + +import * as React from 'react'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { ActionProvider, MetadataCtx, RecordContextProvider } from '@object-ui/react'; +import type { MetadataContextValue } from '@object-ui/react'; + +/** + * The built entry, read from THIS package's `package.json` rather than + * hardcoded, so the precondition names the file the package actually publishes. + * `exports['.'].import` is the ESM entry every consumer resolves. + */ +const PACKAGE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PACKAGE_JSON = JSON.parse( + fs.readFileSync(path.join(PACKAGE_DIR, 'package.json'), 'utf8'), +) as { exports: Record }; +const BUILT_ENTRY = path.resolve(PACKAGE_DIR, PACKAGE_JSON.exports['.'].import); +const BUILT_ENTRY_PRESENT = fs.existsSync(BUILT_ENTRY); + +/** + * ⚠️ THE ONE LINE THAT MAKES THIS A BUILT-ARTIFACT MEASUREMENT. + * + * Relative on purpose: the specifier `@object-ui/components` would be + * redirected to `src` by the root alias map, which is the whole thing this file + * exists to bypass. Held in a variable so Vite cannot resolve it at transform + * time — with a literal specifier, a missing `dist` fails the file's TRANSFORM + * and the precondition below never gets to run, which is the opaque + * MODULE_NOT_FOUND this card names. The bundle's own externals + * (`@object-ui/core`, `@object-ui/react`, `react`) still resolve through the + * Vite pipeline, so the registry it writes to is the same singleton this file + * reads. + * + * Guarded by the precondition so "not built" fails as a NAMED assertion below + * rather than as a module-resolution error. + */ +const BUILT_ENTRY_SPECIFIER = '../../dist/index.js'; +if (BUILT_ENTRY_PRESENT) { + await import(BUILT_ENTRY_SPECIFIER); +} + +/** A script body on the resolved action. Must reach neither the DOM nor the node. */ +const BODY_MARKER = 'OS6252_DIST_BODY_MARKER'; + +const ACTIONS: Record> = { + convert: { name: 'convert', label: 'Convert Lead', type: 'flow', locations: ['record_header'], order: 2 }, + scripted: { + name: 'scripted', + label: 'Run Script', + type: 'script', + locations: ['record_header'], + order: 3, + body: { language: 'js', source: `return { marker: '${BODY_MARKER}' };` }, + }, +}; + +const OBJECT_META = { name: 'lead', label: 'Lead', actions: [ACTIONS.convert, ACTIONS.scripted] }; +const RECORD = { id: 'rec-1', name: 'Ada', status: 'open' }; +const USER = { id: 'u1', systemPermissions: ['setup.access'] }; + +/** + * Held at MODULE level on purpose: `getItem` is an effect dependency of + * `useMetadataItem`, so a value rebuilt per render spins that hook forever. + */ +const METADATA: MetadataContextValue = { + apps: [], + objects: [OBJECT_META], + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: (async (type: string, name: string) => + type === 'object' && name === 'lead' ? OBJECT_META : null) as unknown as MetadataContextValue['getItem'], + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +} as unknown as MetadataContextValue; + +function mount(schema: Record) { + const Component = ComponentRegistry.get('page:header') as React.ComponentType<{ + schema: Record; + }>; + return render( + + + + + + + , + ); +} + +describe('page:header — BUILT artifact (objectui#7183, re-derived from PR objectui#7180)', () => { + /** + * The precondition. It FAILS — it does not skip — because a pin that skips + * itself when its subject is missing is the "green suite that measures + * nothing" this card was filed to prevent. + */ + it('precondition: the package under test has been built', () => { + expect( + BUILT_ENTRY_PRESENT, + `The built entry ${BUILT_ENTRY} does not exist, so this built-artifact pin has ` + + 'NOTHING to measure. It must fail rather than skip. The `dist` vitest project is ' + + 'meant to be reached through `pnpm test:dist`, whose turbo task carries ' + + '`dependsOn: ["build"]` for the package under test and therefore builds it first. ' + + 'Running the project directly builds nothing — build it yourself with ' + + '`pnpm --filter @object-ui/components build`.', + ).toBe(true); + }); + + /** + * THE LIVE CONTROL. Bare `toBeTruthy()` on purpose: with the `await import` + * above removed this reports exactly `expected undefined to be truthy`, the + * verdict PR objectui#7180 recorded. A custom message here would change that + * string and cost the control its recorded form. + */ + it('registers page:header from the BUILT bundle', () => { + expect(ComponentRegistry.get('page:header')).toBeTruthy(); + }); + + it('resolves an action id and carries no body.source into the DOM or the authored node', async () => { + const authored = { type: 'page:header', title: 'Lead', actions: ['convert', 'scripted'] }; + const before = JSON.stringify(authored); + expect(before).not.toContain(BODY_MARKER); + + const { container } = mount(authored); + + // The id resolved THROUGH THE BUILT RENDERER — the action is named by id + // only, so a button carrying its label can only come from a resolution. + expect(await screen.findByRole('button', { name: /Run Script/i })).toBeTruthy(); + + // The handler body reaches neither the rendered DOM ... + expect(container.innerHTML).not.toContain(BODY_MARKER); + // ... nor the authored node, which is what a page build serializes. + expect(JSON.stringify(authored)).toBe(before); + }); +}); diff --git a/scripts/__tests__/turbo-task-guard-coverage.test.ts b/scripts/__tests__/turbo-task-guard-coverage.test.ts index 82082cb87b..c243ba9ac2 100644 --- a/scripts/__tests__/turbo-task-guard-coverage.test.ts +++ b/scripts/__tests__/turbo-task-guard-coverage.test.ts @@ -29,6 +29,7 @@ import { repoRoot } from './helpers/turbo-inputs'; * test cached guarded by turbo-test-inputs.test.ts (#4178) * lint cached guarded by turbo-lint-inputs.test.ts (#4184) * type-check cached guarded by turbo-type-check-inputs.test.ts (#3514) + * test:dist cache: false nothing to replay * test:watch cache: false, persistent nothing to replay * clean cache: false nothing to replay * dev cache: false, persistent nothing to replay diff --git a/turbo.json b/turbo.json index 10d5d32113..1d3db9bd8f 100644 --- a/turbo.json +++ b/turbo.json @@ -45,6 +45,10 @@ "!**/CHANGELOG.md" ] }, + "test:dist": { + "dependsOn": ["build"], + "cache": false + }, "test:watch": { "cache": false, "persistent": true diff --git a/vitest.config.mts b/vitest.config.mts index 700423601d..cb5d701f9d 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -148,6 +148,70 @@ const heavyDomTests = [ 'packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx', ]; +/** + * The `dist` project's population (objectui#7183): built-artifact pins, and + * nothing else. A pin here imports its package's BUILT bundle by an explicit + * relative path, bypassing the `resolve.alias` map below — which is what makes + * "does the shipped bundle still do X" answerable at all, since every alias + * entry redirects a package specifier to its `src`. + * + * The `.dist.spec.tsx` suffix keeps these files out of `unit` (`*.test.ts`), + * `dom` (`*.test.tsx`) and `dom-heavy` (an explicit file list) BY + * CONSTRUCTION, so none of those three projects needed a single character + * changed. It also keeps them out of each package's `tsconfig.test.json`, + * whose include names `*.test.ts` / `*.test.tsx` — deliberately, because + * turbo's `type-check` waits on `^build` (the DEPENDENCIES' builds) and must + * never be handed a program that reads the package's own `dist` + * (objectui#4801 removed a self-referencing `paths` entry for that reason). + */ +const DIST_PIN_GLOB = 'packages/*/src/**/*.dist.spec.tsx'; + +/** + * The `dist` project is OPT-IN, and that is a load-bearing half of + * objectui#7183 rather than a convenience. + * + * CI's test job runs `pnpm test` — `vitest run`, with no build step anywhere in + * it (by design: building every package for every test run is the cost the + * ruling on #7183 explicitly refused). An unconditional fourth project would + * therefore be collected by that run with no `dist` on disk, and its + * precondition would fail the whole suite on every PR. Declaring it only when + * asked for keeps `pnpm test` the run it is today, and confines the build cost + * to the one lane that needs it. + * + * The opt-in is an ENV VAR rather than the `--project dist` flag because the + * flag lives in `process.argv`, which is meaningful only in the process that + * parsed the CLI, while the env var is inherited by everything Vitest spawns. + * `pnpm test:dist` sets it; see `packages/components/package.json`. + */ +const DIST_PINS_ENABLED = process.env.OBJECTUI_DIST_PINS === '1'; + +/** + * ...which leaves exactly one way to get a FALSE GREEN out of the opt-in, and + * it is closed here rather than documented. `vitest run --project dist` without + * the env var would match no project at all; that is a run with nothing in it, + * and `passWithNoTests` is true for a run that names no files — a green that + * measured nothing, which is the exact outcome this card exists to prevent. + * Refuse it instead, and say how. + */ +const DIST_PROJECT_NAMED = process.argv.some( + (arg, i) => arg === '--project=dist' || (arg === '--project' && process.argv[i + 1] === 'dist'), +); +if (DIST_PROJECT_NAMED && !DIST_PINS_ENABLED) { + throw new Error( + [ + 'vitest --project dist was requested, but OBJECTUI_DIST_PINS is not "1", so the', + '`dist` project is NOT declared and this run would collect ZERO files and exit GREEN.', + '', + 'The `dist` project holds built-artifact pins, which need their package BUILT first.', + 'Reach it through the task that guarantees that:', + '', + ' pnpm test:dist # turbo builds the package under test, then runs this project', + '', + 'See vitest.config.mts (DIST_PINS_ENABLED) and turbo.json (the `test:dist` task).', + ].join('\n'), + ); +} + export default defineConfig({ test: { globals: true, @@ -233,6 +297,31 @@ export default defineConfig({ include: [...heavyDomTests], }, }, + // The built-artifact lane (objectui#7183). Deliberately scarce: a project + // that is awkward to reach for stays reserved for genuine published- + // artifact claims instead of drifting into a second default test surface. + // + // The LIGHT dom setup is not a cost optimisation here, it is what makes + // the lane's live control possible. `vitest.setup.dom.tsx` registers + // `page:header` and friends from SOURCE; under it a pin would stay green + // with the built bundle removed entirely, measuring the aliased `src` it + // was written to avoid. Under the light setup nothing registers those + // types, so a pin's own `dist` import is the only thing that can make it + // pass — which is what its live control asserts. + ...(DIST_PINS_ENABLED + ? [ + { + extends: true, + test: { + name: 'dist', + environment: 'happy-dom', + setupFiles: [path.resolve(__dirname, 'vitest.setup.dom-light.tsx')], + include: [DIST_PIN_GLOB], + exclude: sharedExclude, + }, + }, + ] + : []), path.resolve(__dirname, './apps/console/vitest.config.ts'), ], // Tolerate an empty collection ONLY for unfiltered runs (a `--project` From 857c8afc594df4a4e05511ed1696190b344272ff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 04:50:16 +0000 Subject: [PATCH 2/2] fix(vitest): pin the `dist` project's `extends` literal so `projects` still type-checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `dist` project entry added for objectui#7183 sits inside a conditional spread, and inside that array literal `extends: true` widens to `boolean`. `TestProjectConfiguration.extends` is `string | true | undefined`, so the widened element matches no `defineConfig` overload and the whole `projects` array degrades to `never[]`: ../../vitest.config.mts(311,7): error TS2769: No overload matches this call. ../../vitest.config.mts(325,7): error TS2769: No overload matches this call. vitest.config.ts(11,15): error TS2345: Argument of type 'UserConfig & …' is not assignable to parameter of type 'never'. CI reported it on `@object-ui/console#type-check` rather than here, because `apps/console/vitest.config.ts` merges this config and is the type program that reads it — the root `.mts` is compiled by nobody on its own. `as const` keeps the literal narrow. The three pre-existing projects are unaffected: their `extends: true` sits in the plain array, where it never widened. A comment records why the annotation is load-bearing, since it reads like removable noise. Measured red -> green on the reported program, deps built through turbo first (the errors are unreachable behind 378 TS2882s on an unbuilt tree): before exit 1, 3 errors after exit 0, 0 errors Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b --- vitest.config.mts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/vitest.config.mts b/vitest.config.mts index cb5d701f9d..56752f6ee7 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -311,7 +311,14 @@ export default defineConfig({ ...(DIST_PINS_ENABLED ? [ { - extends: true, + // `as const` is load-bearing, not style. Inside this conditional + // array the literal `true` widens to `boolean`, while + // TestProjectConfiguration.extends is `string | true | undefined`; + // the element then matches no overload of defineConfig and the + // whole `projects` array degrades to `never[]`, which also takes + // down apps/console/vitest.config.ts (it merges THIS config, so + // its own type-check is where CI reported it). + extends: true as const, test: { name: 'dist', environment: 'happy-dom',