diff --git a/.changeset/19922-console-manifest-fallback.md b/.changeset/19922-console-manifest-fallback.md new file mode 100644 index 00000000000..3e5132d0005 --- /dev/null +++ b/.changeset/19922-console-manifest-fallback.md @@ -0,0 +1,72 @@ +--- +'@objectstack/cli': minor +--- + +fix(cli)!: a project with no `sdui.manifest.json` of its own has its `kind: 'html'` pages checked against the manifest `@objectstack/console` ships, so `div` and every other undeclared tag or prop is refused (#19922) + +Clause-②: no (narrowing) + + + +**BREAKING for `kind: 'html'` pages in projects without their own manifest.** + +**What changed.** `objectstack validate`, `objectstack compile` / `build` and +`objectstack lint` check the `source` of a `kind: 'html'` page against an SDUI +component manifest. (`dev` and `start` run `compile` before they boot when +`dist/objectstack.json` is missing or `--compile` is passed, and `dev`'s default +watch mode reruns it when a watched file changes.) They look first for the +`sdui.manifest.json` in the directory the command runs in, then for the copy +`@objectstack/console` ships as `dist/sdui.manifest.json`. The second lookup asked for that file by a subpath +the console package does not export, so it always failed, and a project with no +manifest of its own had its html pages checked at parse level only: syntax and +structure, never which components and props they use. The lookup now reaches the +shipped copy, and those pages get full component and prop validation. A tag or +prop the manifest does not declare is refused (`jsx-forbidden-tag`, +`jsx-unknown-component`, `jsx-unknown-prop`), naming the page and the tag, and +the command exits 1. + +**What was refused before, and what is new.** The console has refused a `div` on +a `kind: 'html'` page since `@objectstack/console` 17.5.0: when the page renders, +its in-browser html compile answers `forbidden-tag`, naming `box`. These commands +now give that answer while you author. What they refuse that nothing refused +before is every other tag the manifest does not declare. The console's html +compile accepts every component its registry knows that is not deprecated there, +while the published manifest declares only the public component contract and the +html tier's intrinsic tags. So a page using, for example, `avatar` or `checkbox` +renders in the console and is refused here. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `
` … `
` in a `kind: 'html'` page | `` … ``, which takes the same `className` and children | +| any other tag or prop the command names | a component and prop the manifest declares | + +**What is not affected.** A project that keeps its own `sdui.manifest.json` is +checked against that file, as before. `kind: 'react'` pages and pages authored +as regions are not read by this gate. With no manifest reachable at all, the +pages are still checked at parse level, and the notice that says so is +unchanged. + +**A damaged install is refused, not skipped.** A shipped copy that is present +but cannot be read or parsed stops the command with exit 1, naming the file, +with the remedy: reinstall `@objectstack/console`. + +**A correction to the 17.5.0 note on this manifest.** The `@objectstack/console` +17.5.0 patch entry `28ce612`, the one that says the prebuilt Console dist now +ships `dist/sdui.manifest.json`, ends with a paragraph that this release changes, +sentence by sentence: + +- "For now the file is only present in the tarball." No longer true: the CLI + reads it, as described above. +- "This package's `exports` map exposes `./package.json` and nothing else, so + resolving `@objectstack/console/dist/sdui.manifest.json` through `exports` + fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`." Still true: the `exports` map is + unchanged. +- "Anything that resolves through `exports` cannot read the file yet." Still + true. To read the file, resolve `@objectstack/console/package.json` and join + `dist/sdui.manifest.json` to its directory. +- "That includes the CLI's JSX-page manifest fallback, which catches the error + and keeps parse-level validation, as before." True of the 17.5.0 CLI, false + from this release: the fallback now reads the file that way, so a project + without its own manifest is checked against the shipped copy. diff --git a/packages/cli/src/utils/sdui-manifest.test.ts b/packages/cli/src/utils/sdui-manifest.test.ts index 3a242b54e0c..255996b1346 100644 --- a/packages/cli/src/utils/sdui-manifest.test.ts +++ b/packages/cli/src/utils/sdui-manifest.test.ts @@ -14,15 +14,18 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { validateJsxPages } from '@objectstack/lint'; import { - CONSOLE_SDUI_MANIFEST_SPECIFIER, + CONSOLE_SDUI_MANIFEST, JSX_PARSE_LEVEL_ONLY_RULE, PROJECT_SDUI_MANIFEST_FILE, SduiManifestRefusalError, + consoleSduiManifestPath, countJsxGatePages, jsxGateStacks, printJsxGateNotices, @@ -59,10 +62,11 @@ const PACKAGE_CARRIED: Record> = { const ABSENT: SduiManifestResolution = { status: 'absent', - lookedAt: ['/proj/sdui.manifest.json', CONSOLE_SDUI_MANIFEST_SPECIFIER], + lookedAt: ['/proj/sdui.manifest.json', CONSOLE_SDUI_MANIFEST], }; const UNUSABLE: SduiManifestResolution = { status: 'unusable', + source: 'project', path: '/proj/sdui.manifest.json', reason: 'it is not valid JSON (x)', }; @@ -82,13 +86,17 @@ describe('resolveSduiManifest — says WHY it has no manifest', () => { expect(r).toEqual({ status: 'resolved', manifest: MANIFEST, path: join(dir, PROJECT_SDUI_MANIFEST_FILE) }); }); - // Holds in any checkout: `packages/console/dist/` is gitignored and absent - // unless the console is built, and today the specifier is not in the - // console's `exports` either. Both legs are named, in order. - it('absent: names the project path, then the console specifier', () => { - expect(resolveSduiManifest(dir)).toEqual({ + // Hermetic: the console is located from an origin nothing resolves from + // (`createRequire` refuses a relative one), so the answer does not depend on + // whether this checkout has built the console. ⚠️ An absolute origin in an + // empty directory is NOT that: a runner started through pnpm's `.bin` shim + // inherits a NODE_PATH carrying the virtual store's hoisted packages, and + // `@objectstack/console` resolves from anywhere through it. Both legs are + // named, in order; the console-leg block below pins the absolute spelling. + it('absent: names the project path, then the console copy', () => { + expect(resolveSduiManifest(dir, 'not-an-absolute-origin.mjs')).toEqual({ status: 'absent', - lookedAt: [join(dir, PROJECT_SDUI_MANIFEST_FILE), CONSOLE_SDUI_MANIFEST_SPECIFIER], + lookedAt: [join(dir, PROJECT_SDUI_MANIFEST_FILE), CONSOLE_SDUI_MANIFEST], }); }); @@ -103,6 +111,7 @@ describe('resolveSduiManifest — says WHY it has no manifest', () => { const r = resolveSduiManifest(dir); expect(r.status).toBe('unusable'); if (r.status !== 'unusable') return; + expect(r.source).toBe('project'); expect(r.path).toBe(join(dir, PROJECT_SDUI_MANIFEST_FILE)); expect(r.reason).toMatch(reason); }); @@ -115,6 +124,113 @@ describe('resolveSduiManifest — says WHY it has no manifest', () => { }); }); +/** + * The `package.json` of the REAL `@objectstack/console` this package depends + * on, resolved the way any installed dependency is — through `node_modules` — + * so the layouts below carry the console's actual `exports` map, which is what + * decides whether a subpath resolves at all. + */ +const REAL_CONSOLE_PACKAGE_JSON = createRequire(import.meta.url).resolve('@objectstack/console/package.json'); + +/** + * An installed-package layout under `root`: `node_modules/@objectstack/console` + * carrying the real console `package.json`, plus a `dist/sdui.manifest.json` + * with `body` (`null` for a console that ships none, as 17.0.0 to 17.4.0 did). + * Returns the origin a CLI installed beside it resolves from. + */ +function installConsole(root: string, body: string | null): URL { + const pkgDir = join(root, 'node_modules', '@objectstack', 'console'); + mkdirSync(join(pkgDir, 'dist'), { recursive: true }); + writeFileSync(join(pkgDir, 'package.json'), readFileSync(REAL_CONSOLE_PACKAGE_JSON, 'utf8')); + if (body !== null) writeFileSync(join(pkgDir, 'dist', 'sdui.manifest.json'), body); + return pathToFileURL(join(root, 'node_modules', '@objectstack', 'cli', 'dist', 'index.js')); +} + +describe('the console leg — the copy @objectstack/console ships is reached, through its package.json', () => { + let root = ''; + let project = ''; + beforeEach(() => { + // Real path: module resolution answers with one (`/var` is `/private/var` on macOS). + root = realpathSync(mkdtempSync(join(tmpdir(), 'os-sdui-console-'))); + project = join(root, 'project'); + mkdirSync(project); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + // The production default: from the CLI's OWN location, where its declared + // dependency lives. Asking for the file by its own subpath resolves nothing + // (the console's `exports` publishes `./package.json` alone), so this reds + // the moment the leg goes back to that spelling. + it("locates the CLI's own @objectstack/console dependency, whether or not it is built", () => { + const path = consoleSduiManifestPath(); + expect(path).toBeDefined(); + expect(path!.endsWith(join('dist', 'sdui.manifest.json'))).toBe(true); + const owner = JSON.parse(readFileSync(join(dirname(dirname(path!)), 'package.json'), 'utf8')); + expect(owner.name).toBe('@objectstack/console'); + }); + + it('resolved: a project with no manifest of its own is checked against the shipped copy', () => { + const origin = installConsole(root, JSON.stringify(MANIFEST)); + expect(resolveSduiManifest(project, origin)).toEqual({ + status: 'resolved', + manifest: MANIFEST, + path: join(root, 'node_modules', '@objectstack', 'console', 'dist', 'sdui.manifest.json'), + }); + }); + + it("resolved: the project's own manifest is read first", () => { + const origin = installConsole(root, JSON.stringify({ components: {} })); + writeFileSync(join(project, PROJECT_SDUI_MANIFEST_FILE), JSON.stringify(MANIFEST)); + expect(resolveSduiManifest(project, origin)).toEqual({ + status: 'resolved', + manifest: MANIFEST, + path: join(project, PROJECT_SDUI_MANIFEST_FILE), + }); + }); + + it('absent: a console that ships no manifest is named by the absolute path looked at', () => { + const origin = installConsole(root, null); + expect(resolveSduiManifest(project, origin)).toEqual({ + status: 'absent', + lookedAt: [ + join(project, PROJECT_SDUI_MANIFEST_FILE), + join(root, 'node_modules', '@objectstack', 'console', 'dist', 'sdui.manifest.json'), + ], + }); + }); + + it('unusable: a damaged shipped copy is refused with its own remedy, never read as "not found"', () => { + const origin = installConsole(root, '{ "components": [ oops'); + const r = resolveSduiManifest(project, origin); + expect(r).toMatchObject({ + status: 'unusable', + source: 'console', + path: join(root, 'node_modules', '@objectstack', 'console', 'dist', 'sdui.manifest.json'), + }); + + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + let thrown: unknown; + try { + resolveJsxGateManifest(HTML_STACK, r); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(SduiManifestRefusalError); + const e = thrown as SduiManifestRefusalError; + expect(e.message).toContain(join('@objectstack', 'console', 'dist', 'sdui.manifest.json')); + // The remedy names the package to reinstall, not the file to edit. + const remedy = e.hints[e.hints.length - 1]; + expect(remedy).toContain('@objectstack/console'); + expect(remedy).not.toContain(PROJECT_SDUI_MANIFEST_FILE); + } finally { + errSpy.mockRestore(); + } + }); +}); + describe('countJsxGatePages — the pages the JSX gate checks against a manifest', () => { // The kind set is `validateJsxPages`'s own, read by DRIVING it: with a // manifest that declares no components, every page the gate compiles against diff --git a/packages/cli/src/utils/sdui-manifest.ts b/packages/cli/src/utils/sdui-manifest.ts index 41c4549ffe8..be1f8d4ad33 100644 --- a/packages/cli/src/utils/sdui-manifest.ts +++ b/packages/cli/src/utils/sdui-manifest.ts @@ -29,12 +29,15 @@ * untouched on every face, `--strict` included: failing here * would break every project that has no manifest of its own, * and such a project has no remedy but to author one. - * - `unusable` → a manifest the PROJECT put in place that cannot be read, - * parsed, or carries no `components` map. REFUSED (exit 1), - * never degraded: its author asked for full validation, and - * the `{}` shape already crashed the gate with a bare - * TypeError while `{ oops` passed it silently — one rule now - * covers both, with the file and the reason named. + * - `unusable` → a manifest that is present but cannot be read, parsed, or + * carries no `components` map. REFUSED (exit 1), never + * degraded: its author asked for full validation, and the + * `{}` shape already crashed the gate with a bare TypeError + * while `{ oops` passed it silently — one rule now covers + * both, with the file and the reason named. The same rule + * holds for the copy `@objectstack/console` ships (below): + * a damaged install is refused with that remedy, never read + * as "not found". * * Both the notice and the refusal fire only when the run has a page the JSX * gate actually checks. With none, the manifest is read by nothing, so a @@ -48,16 +51,22 @@ * layout got no notice and a broken manifest passed at exit 0: the silent * degradation this module exists to end, one layout over (#20113 round 1). * - * ⛔ The console leg's FAILURE semantics are deliberately unchanged: the - * specifier below resolves to nothing today (the console's `exports` map does - * not publish that subpath), and whatever makes it reachable owns what a broken - * shipped copy should do. Until then a failure there reads as "not found", and - * the `absent` notice names the location, so it is not silent either. + * ## The console leg is reached through `package.json` (#19922) + * + * The second place looked is the copy `@objectstack/console` ships in its + * `dist/` — objectui's public-tier registry at the pinned commit, copied in by + * `scripts/build-console.sh`. This leg used to ask for that file by its own + * subpath, which the console's `exports` map does not publish (it publishes + * `./package.json` alone): the resolve threw `ERR_PACKAGE_PATH_NOT_EXPORTED`, a + * `catch` swallowed it, and a project with no manifest of its own was checked + * at parse level even where the console shipped the file. It now resolves the + * console's `package.json` from the CLI's OWN location and joins the file's + * path to it ({@link consoleSduiManifestPath}), which keeps `exports` closed. */ import { existsSync, readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import chalk from 'chalk'; import type { AuthoringFinding } from '@objectstack/lint'; import { artifactPackages, packageBodyAsStack } from './artifact-packages.js'; @@ -67,8 +76,21 @@ import { authoringRuleUnionStack } from './stack-collections.js'; /** The file the project provides, looked for in the working directory. */ export const PROJECT_SDUI_MANIFEST_FILE = 'sdui.manifest.json'; -/** The copy shipped inside `@objectstack/console` — the second place looked. */ -export const CONSOLE_SDUI_MANIFEST_SPECIFIER = '@objectstack/console/dist/sdui.manifest.json'; +/** + * The copy shipped inside `@objectstack/console` — the second place looked — + * named as a package-relative path. ⛔ A name, not a specifier to resolve: the + * console's `exports` map publishes `./package.json` alone, so resolving this + * subpath throws `ERR_PACKAGE_PATH_NOT_EXPORTED`. {@link consoleSduiManifestPath} + * reaches the file; this spelling names it only when `@objectstack/console` + * itself cannot be resolved. + */ +export const CONSOLE_SDUI_MANIFEST = '@objectstack/console/dist/sdui.manifest.json'; + +/** The one subpath the console's `exports` map publishes. */ +const CONSOLE_PACKAGE_JSON = '@objectstack/console/package.json'; + +/** Where `scripts/build-console.sh` puts the manifest, relative to the console package root. */ +const CONSOLE_MANIFEST_IN_PACKAGE = 'dist/sdui.manifest.json'; /** * The rule id the parse-level notice carries on every face: the `rule` of the @@ -88,12 +110,18 @@ export type SduiManifestResolution = } | { readonly status: 'absent'; - /** Every place looked, in order: an absolute path, then a package specifier. */ + /** + * Every place looked, in order: the project's absolute path, then the + * console copy's — absolute when `@objectstack/console` resolves, else + * {@link CONSOLE_SDUI_MANIFEST}. + */ readonly lookedAt: readonly string[]; } | { readonly status: 'unusable'; - /** The project manifest that exists but cannot be used. */ + /** Whose file: the project's own, or the copy `@objectstack/console` ships. */ + readonly source: 'project' | 'console'; + /** The manifest file that exists but cannot be used. */ readonly path: string; /** Why, as a clause: `it is not valid JSON (…)`. */ readonly reason: string; @@ -111,48 +139,70 @@ function isRecord(value: unknown): value is AnyRec { * manifest.components)`), so it is the shape floor — deeper checking is the * gate's own business once it has a manifest to check against. */ -function readManifestFile(path: string): SduiManifestResolution { +function readManifestFile(path: string, source: 'project' | 'console'): SduiManifestResolution { let text: string; try { text = readFileSync(path, 'utf8'); } catch (error) { - return { status: 'unusable', path, reason: `it could not be read (${(error as Error).message})` }; + return { status: 'unusable', source, path, reason: `it could not be read (${(error as Error).message})` }; } let parsed: unknown; try { parsed = JSON.parse(text); } catch (error) { - return { status: 'unusable', path, reason: `it is not valid JSON (${(error as Error).message})` }; + return { status: 'unusable', source, path, reason: `it is not valid JSON (${(error as Error).message})` }; } if (!isRecord(parsed) || !isRecord(parsed.components)) { - return { status: 'unusable', path, reason: 'it is not a JSON object with a `components` map' }; + return { status: 'unusable', source, path, reason: 'it is not a JSON object with a `components` map' }; } return { status: 'resolved', manifest: parsed, path }; } /** - * The manifest for the project in `cwd`, or the reason there is none. Never + * Where `@objectstack/console` keeps the manifest it ships, as an absolute + * path, or `undefined` when that package cannot be resolved from `origin`. + * Whether the file exists there is the caller's question. + * + * Reached through the console's `package.json` plus a join — the way + * `resolveConsolePath()` already locates this static-asset package — because + * the file's own subpath is not in the console's `exports` (see + * {@link CONSOLE_SDUI_MANIFEST}). + * + * `origin` defaults to THIS module, so the console found is the CLI's own + * declared dependency, released in one fixed version group with it. ⛔ Not + * `cwd`: under pnpm a project that does not itself depend on + * `@objectstack/console` cannot resolve it from its own directory, and the + * gate's strength would then depend on hoisting. The parameter exists for the + * pins, which drive an installed-package layout. + */ +export function consoleSduiManifestPath(origin: string | URL = import.meta.url): string | undefined { + let packageJson: string; + try { + packageJson = createRequire(origin).resolve(CONSOLE_PACKAGE_JSON); + } catch { + return undefined; + } + return join(dirname(packageJson), CONSOLE_MANIFEST_IN_PACKAGE); +} + +/** + * The manifest for the project in `cwd`, or the reason there is none: the + * project's own file first, then the copy `@objectstack/console` ships + * (located from `consoleOrigin`, see {@link consoleSduiManifestPath}). Never * throws: what an `unusable` answer costs is the caller's decision * ({@link resolveJsxGateManifest} refuses it; `init`'s scaffold check, which * reads the INVOKER's directory rather than the project's, does not). */ -export function resolveSduiManifest(cwd: string = process.cwd()): SduiManifestResolution { +export function resolveSduiManifest( + cwd: string = process.cwd(), + consoleOrigin: string | URL = import.meta.url, +): SduiManifestResolution { const projectManifest = join(cwd, PROJECT_SDUI_MANIFEST_FILE); - if (existsSync(projectManifest)) return readManifestFile(projectManifest); + if (existsSync(projectManifest)) return readManifestFile(projectManifest, 'project'); - // Fall back to the manifest shipped inside @objectstack/console (built from - // objectui's public-tier registry; the CLI already depends on it). See the - // header: this leg's failure semantics are not this module's to change. - try { - const consoleManifest = createRequire(import.meta.url).resolve(CONSOLE_SDUI_MANIFEST_SPECIFIER); - if (existsSync(consoleManifest)) { - const fromConsole = readManifestFile(consoleManifest); - if (fromConsole.status === 'resolved') return fromConsole; - } - } catch { - /* not found — reported below as a place looked */ - } - return { status: 'absent', lookedAt: [projectManifest, CONSOLE_SDUI_MANIFEST_SPECIFIER] }; + const consoleManifest = consoleSduiManifestPath(consoleOrigin); + if (consoleManifest !== undefined && existsSync(consoleManifest)) return readManifestFile(consoleManifest, 'console'); + return { status: 'absent', lookedAt: [projectManifest, consoleManifest ?? CONSOLE_SDUI_MANIFEST] }; } /** @@ -276,7 +326,9 @@ export function resolveJsxGateManifest( const hints = [ ` The JSX page gate reads this file to check the components and props of ${pages} kind:'html' ` + `page(s), and it does not fall back to parse-level checking while the file is present.`, - ' Fix the file (a JSON object with a `components` map), or remove it to check those pages at parse level only.', + resolution.source === 'project' + ? ' Fix the file (a JSON object with a `components` map), or remove it to check those pages at parse level only.' + : ' It is the copy @objectstack/console ships, so that install is damaged: reinstall @objectstack/console.', ]; printErrorToStderr(message); console.error(''); diff --git a/packages/cli/test/build-json-failure-conversions.e2e.test.ts b/packages/cli/test/build-json-failure-conversions.e2e.test.ts index 3b3b1b2b3ab..f834b940209 100644 --- a/packages/cli/test/build-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/build-json-failure-conversions.e2e.test.ts @@ -130,6 +130,10 @@ function payloadOf(run: Run, label: string): Record { * A stack whose `pages[0].kind` drives the live conversion. `pageKind` is a * parameter so the negative control can run the identical shape with the * CANONICAL spelling, where there is nothing to convert. + * + * The page is ``, not `
`: where the CLI reaches the manifest + * `@objectstack/console` ships, the html tier refuses `div` (#19922), and that + * would add an author-time error no exit here is about. */ function stack(ns: string, opts: { pageKind?: string; requires?: string[]; extraFields?: string; extraTop?: string } = {}): string { const { pageKind = 'jsx', requires = [], extraFields = '', extraTop = '' } = opts; @@ -139,7 +143,7 @@ import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, requires: [${requires.map((r) => `'${r}'`).join(', ')}], - pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: '
hi
' }], + pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: 'hi' }], objects: [ { name: '${ns}_ticket', diff --git a/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts b/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts index 712a4720463..bbb81cd327e 100644 --- a/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts +++ b/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts @@ -42,17 +42,34 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { execFile } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; +import { consoleSduiManifestPath } from '../src/utils/sdui-manifest.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); +/** + * Whether the manifest-less path is reachable in THIS checkout (#19922). With + * no project manifest the CLI falls back to the copy its own + * `@objectstack/console` dependency ships, located from the same module the + * spawned CLI runs (`src/`, through `bin/run-dev.js`). In the workspace that is + * `packages/console/dist/sdui.manifest.json`, which exists only where the + * console has been built — never in the CI job that runs this file, often on a + * developer's machine. Where it exists, a project without a manifest is fully + * validated against it and the notice has nothing to report, so the cases that + * need "no manifest anywhere" are SKIPPED, by name, rather than asserting a + * state this checkout cannot produce. Every rule they pin is also pinned, + * hermetically, in `src/utils/sdui-manifest.test.ts`. + */ +const CONSOLE_COPY = consoleSduiManifestPath(); +const NO_MANIFEST_UNREACHABLE = CONSOLE_COPY !== undefined && existsSync(CONSOLE_COPY); + /** `JSX_PARSE_LEVEL_ONLY_RULE` in `src/utils/sdui-manifest.ts`, spelled out as the published anchor. */ const RULE = 'sdui/jsx-parse-level-only'; @@ -259,7 +276,7 @@ afterAll(() => { if (root) rmSync(root, { recursive: true, force: true }); }); -describe('no manifest, kind:html pages — the notice, exit status unchanged', () => { +describe.skipIf(NO_MANIFEST_UNREACHABLE)('no manifest, kind:html pages — the notice, exit status unchanged', () => { it.each([ ['validate', '--strict'], ['build'], @@ -364,7 +381,7 @@ describe('[round 1] html pages carried only in packages[], beside a top-level pa COMMANDS.map((command) => [layout, command, notice, malformed] as const), ); - it.each(rows)('%s — os %s: the notice, counting the package page, exit 0', (_layout, command, notice) => { + it.skipIf(NO_MANIFEST_UNREACHABLE).each(rows)('%s — os %s: the notice, counting the package page, exit 0', (_layout, command, notice) => { const r = run(notice, command, '--json'); expect(r.code, r.stdout + r.stderr).toBe(0); const notices = noticesIn(payloadOf(r, `${notice} ${command}`)); diff --git a/packages/cli/test/lint-conversion-notices.e2e.test.ts b/packages/cli/test/lint-conversion-notices.e2e.test.ts index 99e7d66cf07..c5aa18a075e 100644 --- a/packages/cli/test/lint-conversion-notices.e2e.test.ts +++ b/packages/cli/test/lint-conversion-notices.e2e.test.ts @@ -123,13 +123,15 @@ function payloadOf(run: Run, label: string): Record { * * `source` is a single-root element: the `jsx-no-root` authoring rule rejects a * bare string, so a fixture that used one would never reach the exit under test. + * It is ``, not `
`: where the CLI reaches the manifest + * `@objectstack/console` ships, the html tier refuses `div` (#19922). */ function stack(ns: string, opts: { pageKind?: string } = {}): string { const { pageKind = 'jsx' } = opts; return ` export default { manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, - pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: '
hi
' }], + pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: 'hi' }], objects: [ { name: '${ns}_ticket', diff --git a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts index 5d7157ac535..e93015db07f 100644 --- a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts @@ -158,6 +158,10 @@ function payloadOf(run: Run, label: string): Record { * A stack whose `pages[0].kind` drives the live conversion. `pageKind` is a * parameter so the negative control can run the identical shape with the * CANONICAL spelling, where there is nothing to convert. + * + * The page is ``, not `
`: where the CLI reaches the manifest + * `@objectstack/console` ships, the html tier refuses `div` (#19922), and that + * would add an author-time error no exit here is about. */ function stack(ns: string, opts: { pageKind?: string; requires?: string[]; extraFields?: string } = {}): string { const { pageKind = 'jsx', requires = [], extraFields = '' } = opts; @@ -167,7 +171,7 @@ import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, requires: [${requires.map((r) => `'${r}'`).join(', ')}], - pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: '
hi
' }], + pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: 'hi' }], objects: [ { name: '${ns}_ticket',