diff --git a/.changeset/28956-config-load-zod-refusal.md b/.changeset/28956-config-load-zod-refusal.md new file mode 100644 index 00000000000..7bfea0d777a --- /dev/null +++ b/.changeset/28956-config-load-zod-refusal.md @@ -0,0 +1,19 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): a `define*` helper that refuses its input while the config loads is reported as one located refusal (file, line, helper, path, the issue in words), not a raw JSON array + +Clause-②: no — CLI output text only. No schema, export or accepted input changes; a refused config is refused with the same exit code as before. +Metadata-protocol: none + +`defineView`, `defineAction` and the other strict helpers call `Schema.parse` and throw the `ZodError` as is. `os validate`, `os build`, `os lint`, `os dev` and `os serve` printed that error's message, which is its issues as a JSON array, with no file and no helper. The config module now loads through one seam that turns such an error into a refusal rendered by `formatZodError`: + +```text +✗ src/views/ticket.view.ts:5:28 · defineView validation failed (1 issue): + + ✗ list.columns: Invalid input + ✗ list.columns.0: Invalid input: expected string, received object +``` + +The file and line come from the evaluated bundle's source map. When the stack cannot tell which module threw, the refusal names the config entry and says so. The `--json` envelope's `error` carries the same text. Every other load failure (a syntax error, a missing import, a helper's own prose refusal, `defineStack`'s formatted refusal) is unchanged. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 6df3659aba8..6ca64393f5c 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -5,9 +5,8 @@ import path from 'path'; import fs from 'fs'; import net from 'net'; import chalk, { chalkStderr } from 'chalk'; -import { bundleRequire } from 'bundle-require'; import { stampDatasourceEnvCredentialsDefault } from '../utils/datasource-env-credentials-default.js'; -import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; +import { loadConfig, bundleConfigModule } from '../utils/config.js'; import { mergeBootConfig } from '../utils/merge-boot-config.js'; import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../utils/telemetry-datasource.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; @@ -2762,7 +2761,7 @@ export default class Serve extends Command { ? { mod: { default: {} as any } } : flags.prebuilt ? { mod: await import(absolutePath.startsWith('/') ? `file://${absolutePath}` : absolutePath) } - : await bundleRequire({ filepath: absolutePath, external: BUNDLE_REQUIRE_EXTERNALS }); + : await bundleConfigModule(absolutePath); let config = mod.default || mod; diff --git a/packages/cli/src/utils/config-load-refusal.ts b/packages/cli/src/utils/config-load-refusal.ts new file mode 100644 index 00000000000..a6e3776f5c7 --- /dev/null +++ b/packages/cli/src/utils/config-load-refusal.ts @@ -0,0 +1,249 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The refusal a config load raises when a `define*` helper's `Schema.parse` + * throws a raw `ZodError` while the config MODULE evaluates. + * + * ## The defect this closes + * + * `defineView`, `defineAction` and the other strict helpers call + * `Schema.parse` and let the `ZodError` out as is. Nothing on the normal load + * path wrapped it, and every command's catch-all printed `error.message` — + * which for a `ZodError` is its issues as a JSON array. Measured on this tree + * with a `defineView` mixing string and object `list.columns`: `os validate`, + * `os build`, `os lint`, `os dev` and `os serve` all printed `✗ [` and 40-odd + * lines of JSON, naming no file, no helper and no remedy. + * + * ## What it renders, and with what + * + * The issues go through the project's own `formatZodError` — the formatter the + * `os migrate meta` shim's `__refusal` and the loader's own `defineStack + * validation failed` block already use, union branch expansion included. There + * is no second formatter here: this file only works out WHERE the refused call + * is, and puts that in front of the block. + * + * ## How the location is read, and when it is not + * + * bundle-require evaluates one esbuild bundle of the config and every module + * it imports (an `@objectstack/*` package or a `node_modules` dependency stays + * external), with an inline source map. The innermost stack frame inside that + * bundle is therefore the authored call that threw, and the map turns it back + * into the authored file, line and column. The frame directly above it is the + * function that call entered: the helper, named only when it is a `define*` + * function whose name the authored line actually spells. + * + * ⛔ Never a guessed file. When the stack holds no frame in the bundle (a + * truncated stack, an error rethrown from a promise), when the map has no + * entry on that generated line, or when the mapped file is not on disk, + * {@link locateConfigLoadZodError} answers `undefined` and the refusal names + * the config entry it was loading, saying the module that threw is unknown. + */ + +import fs from 'fs'; +import path from 'path'; +import { SourceMap } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { formatZodError } from '@objectstack/spec'; + +/** The bundle bundle-require evaluated: its path on disk and its text. */ +export interface EvaluatedConfigBundle { + readonly file: string; + readonly text: string; +} + +/** Where the refused call is in the authored source. */ +export interface ConfigLoadZodLocation { + /** Absolute path of the authored module that made the refused call. */ + readonly file: string; + /** 1-based. */ + readonly line: number; + /** 1-based. */ + readonly column: number; + /** The `define*` helper the call entered, when the stack and the line agree on it. */ + readonly helper?: string; +} + +/** A `ZodError` as `Schema.parse` throws it: by shape, not `instanceof` (two zod copies can be live). */ +export function isRawZodError(error: unknown): error is Error & { issues: unknown[] } { + return ( + error instanceof Error + && error.name === 'ZodError' + && Array.isArray((error as { issues?: unknown }).issues) + ); +} + +const DEFINE_HELPER_NAME_RE = /^define[A-Z][A-Za-z0-9]*$/; +const INLINE_SOURCE_MAP_RE = /\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)\s*$/; +const STACK_FRAME_RE = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?$/; + +interface StackFrame { + readonly fn?: string; + readonly file: string; + readonly line: number; + readonly column: number; +} + +function stackFramesOf(stack: string): StackFrame[] { + const frames: StackFrame[] = []; + for (const raw of stack.split('\n')) { + const match = STACK_FRAME_RE.exec(raw); + if (!match) continue; + const [, fn, location, line, column] = match; + let file = location; + if (file.startsWith('async ')) file = file.slice('async '.length); + if (file.startsWith('file://')) { + try { + file = fileURLToPath(file); + } catch { + continue; + } + } + frames.push({ fn: fn?.replace(/^async /, ''), file, line: Number(line), column: Number(column) }); + } + return frames; +} + +function sourceMapOf(bundle: EvaluatedConfigBundle): { map: SourceMap; sources: Set } | undefined { + const match = INLINE_SOURCE_MAP_RE.exec(bundle.text); + if (!match) return undefined; + try { + const payload = JSON.parse(Buffer.from(match[1], 'base64').toString('utf8')); + return { + map: new SourceMap(payload), + sources: new Set(Array.isArray(payload.sources) ? payload.sources : []), + }; + } catch { + return undefined; + } +} + +/** A map `sources` entry as an absolute path: esbuild writes them relative to its working directory. */ +function absoluteSource(source: string, cwd: string): string | undefined { + if (source.startsWith('file://')) { + try { + return fileURLToPath(source); + } catch { + return undefined; + } + } + return path.resolve(cwd, source); +} + +/** The helper the frame names, when it is a `define*` function and the authored line spells that name. */ +function helperOf(frame: StackFrame | undefined, file: string, line: number): string | undefined { + const name = frame?.fn?.split('.').pop(); + if (!name || !DEFINE_HELPER_NAME_RE.test(name)) return undefined; + let text: string | undefined; + try { + text = fs.readFileSync(file, 'utf8').split(/\r?\n/)[line - 1]; + } catch { + return undefined; + } + return text !== undefined && new RegExp(`\\b${name}\\b`).test(text) ? name : undefined; +} + +/** + * The authored file, line, column and helper of the call that threw `stack` + * while `bundle` evaluated, or `undefined` when the stack cannot tell. + * + * @param stack the thrown error's `stack` + * @param bundle the bundle bundle-require evaluated, read before it was deleted + * @param cwd the working directory esbuild resolved the map's `sources` against + */ +export function locateConfigLoadZodError( + stack: string | undefined, + bundle: EvaluatedConfigBundle | undefined, + cwd: string, +): ConfigLoadZodLocation | undefined { + if (!stack || !bundle) return undefined; + const mapped = sourceMapOf(bundle); + if (!mapped) return undefined; + const authored = new Set(); + for (const source of mapped.sources) { + const absolute = absoluteSource(source, cwd); + if (absolute) authored.add(absolute); + } + + const frames = stackFramesOf(stack); + for (let i = 0; i < frames.length; i++) { + const frame = frames[i]; + let located: { file: string; line: number; column: number } | undefined; + if (frame.file === bundle.file) { + // V8 frames are 1-based; the map is 0-based. The entry must sit on the + // frame's own generated line — `findEntry` otherwise answers the nearest + // earlier mapping, which can be another module's code. + const entry = mapped.map.findEntry(frame.line - 1, frame.column - 1) as { + generatedLine?: number; + originalSource?: string; + originalLine?: number; + originalColumn?: number; + }; + if ( + entry.generatedLine !== frame.line - 1 + || typeof entry.originalSource !== 'string' + || typeof entry.originalLine !== 'number' + || typeof entry.originalColumn !== 'number' + ) { + return undefined; + } + const file = absoluteSource(entry.originalSource, cwd); + if (!file) return undefined; + located = { file, line: entry.originalLine + 1, column: entry.originalColumn + 1 }; + } else if (authored.has(frame.file)) { + // `--enable-source-maps`: V8 already mapped the frame to the authored file. + located = { file: frame.file, line: frame.line, column: frame.column }; + } + if (!located) continue; + if (!fs.existsSync(located.file)) return undefined; + const helper = helperOf(frames[i - 1], located.file, located.line); + return helper ? { ...located, helper } : located; + } + return undefined; +} + +/** A path as the author reads it: relative to the working directory when it is under it. */ +function displayPath(file: string, cwd: string): string { + const relative = path.relative(cwd, file); + return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file; +} + +/** + * The readable refusal for a raw `ZodError` a config load threw. + * + * A plain `Error`, so every command's catch-all prints it exactly where it + * printed the JSON array before — `--json` copies `message` into its + * envelope's `error` — and no catch-all changes. The `ZodError` rides as + * `cause`. + */ +export class ConfigLoadRefusalError extends Error { + constructor(message: string, cause: Error) { + super(message, { cause }); + this.name = 'ConfigLoadRefusalError'; + } +} + +/** + * Turn a raw `ZodError` thrown while `configPath` loaded into + * {@link ConfigLoadRefusalError}: + * + * ```text + * src/views/ticket.view.ts:5:28 · defineView validation failed (1 issue): + * + * ✗ list.columns: Invalid input + * ✗ list.columns.0: Invalid input: expected string, received object + * ``` + */ +export function configLoadRefusal( + error: Error & { issues: unknown[] }, + configPath: string, + bundle: EvaluatedConfigBundle | undefined, + cwd: string, +): ConfigLoadRefusalError { + const location = locateConfigLoadZodError(error.stack, bundle, cwd); + const where = location + ? `${displayPath(location.file, cwd)}:${location.line}:${location.column}` + : `${displayPath(configPath, cwd)} (loading it threw; the stack does not say which module)`; + const label = location?.helper ? `${location.helper} validation failed` : undefined; + const block = formatZodError(error as unknown as Parameters[0], label); + return new ConfigLoadRefusalError(`${where} · ${block}`, error); +} diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 3c669569443..82be1e8df47 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -3,12 +3,13 @@ import path from 'path'; import fs from 'fs'; import { createRequire } from 'node:module'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import chalk from 'chalk'; -import { bundleRequire } from 'bundle-require'; +import { bundleRequire, dynamicImport } from 'bundle-require'; import type { Plugin } from 'esbuild'; import { hasStackProvenance, stackConversionsOf, type ConversionNotice } from '@objectstack/spec'; import { printErrorToStderr, printWarningToStderr } from './format.js'; +import { configLoadRefusal, isRawZodError, type EvaluatedConfigBundle } from './config-load-refusal.js'; export interface LoadedConfig { config: any; @@ -769,6 +770,51 @@ function authoredArgumentOf(value: unknown): unknown { return current; } +/** + * Bundle and evaluate the config module — the ONE place a config's module + * graph runs, for {@link loadConfig} and for `os serve`'s own load. + * + * A raw `ZodError` thrown while the module evaluates — a `define*` helper's + * `Schema.parse` refusing its input — is rethrown as the located, formatted + * refusal of `./config-load-refusal.ts` instead of reaching a catch-all whose + * `error.message` is the issues as a JSON array. Every other throw (a syntax + * error, a missing import, a helper's own prose refusal, `defineStack`'s + * formatted one) passes through untouched. + * + * The bundle's text is read just before bundle-require imports it, because + * bundle-require deletes the file once the import settles; the refusal needs + * its inline source map to turn the stack frame back into the authored file. + * + * @param plugins esbuild plugins to bundle with (the authored-source shim) + */ +export async function bundleConfigModule( + absolutePath: string, + plugins?: Plugin[], +): Promise<{ mod: any }> { + const cwd = process.cwd(); + let bundle: EvaluatedConfigBundle | undefined; + try { + return await bundleRequire({ + filepath: absolutePath, + cwd, + external: BUNDLE_REQUIRE_EXTERNALS, + ...(plugins ? { esbuildOptions: { plugins } } : {}), + require: (outfile, ctx) => { + const file = outfile.startsWith('file:') ? fileURLToPath(outfile) : outfile; + try { + bundle = { file, text: fs.readFileSync(file, 'utf8') }; + } catch { + // Unreadable: the refusal then names the config entry, never a guess. + } + return dynamicImport(outfile, ctx); + }, + }); + } catch (error) { + if (isRawZodError(error)) throw configLoadRefusal(error, absolutePath, bundle, cwd); + throw error; + } +} + /** * Load and bundle a config file using bundle-require. * Returns the resolved config object, its load time, and the provenance of @@ -815,13 +861,10 @@ export async function loadConfig(source?: string, options?: LoadConfigOptions): const absolutePath = resolveConfigPath(source); const start = Date.now(); - const { mod } = await bundleRequire({ - filepath: absolutePath, - external: BUNDLE_REQUIRE_EXTERNALS, - ...(options?.authoredSource - ? { esbuildOptions: { plugins: [authoredSourcePlugin(absolutePath)] } } - : {}), - }); + const { mod } = await bundleConfigModule( + absolutePath, + options?.authoredSource ? [authoredSourcePlugin(absolutePath)] : undefined, + ); const baseConfig = mod.default || mod; if (!baseConfig) { diff --git a/packages/cli/test/config-load-zod-refusal.test.ts b/packages/cli/test/config-load-zod-refusal.test.ts new file mode 100644 index 00000000000..02805234694 --- /dev/null +++ b/packages/cli/test/config-load-zod-refusal.test.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A `define*` helper's `ZodError` thrown while the config module loads reaches + * the author as ONE located, formatted refusal — never the issues as a JSON + * array — on every command that loads the config. + * + * The case is the one a newcomer hits first: a `defineView` whose + * `list.columns` mixes a column object with bare field names, in its own view + * module re-exported through an index. Before the load seam, `os validate` + * and `os build` printed `✗ [` and the raw issue array, naming no file and no + * helper. + * + * Controls: a config that loads cleanly, a module that does not resolve, a + * syntax error and a helper's own prose refusal all keep exactly what the + * bundle load threw. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { bundleRequire } from 'bundle-require'; +import { z } from 'zod'; +import Compile from '../src/commands/compile'; +import Validate from '../src/commands/validate'; +import { BUNDLE_REQUIRE_EXTERNALS, loadConfig } from '../src/utils/config'; +import { configLoadRefusal } from '../src/utils/config-load-refusal'; +import { linkSpec } from './helpers/define-stack-fixture.js'; + +const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); + +interface Run { + code: number; + stdout: string; +} + +async function run(command: typeof Compile | typeof Validate, argv: string[]): Promise { + const chunks: string[] = []; + const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => { + chunks.push(String(chunk)); + const done = rest.find((a) => typeof a === 'function') as ((e?: Error | null) => void) | undefined; + done?.(null); + return true; + }) as never); + const exitCodeBefore = process.exitCode; + let code = 0; + try { + await command.run(argv, { root: CLI_ROOT }); + } catch (error) { + const exit = (error as { oclif?: { exit?: unknown } }).oclif?.exit; + if (typeof exit !== 'number') throw error; + code = exit; + } finally { + spy.mockRestore(); + process.exitCode = exitCodeBefore; + } + // eslint-disable-next-line no-control-regex + return { code, stdout: chunks.join('').replace(/\u001b\[[0-9;]*m/g, '') }; +} + +const MANIFEST = `{ id: 'com.example.support-desk', namespace: 'support_desk', version: '0.1.0', type: 'app', name: 'Support Desk' }`; + +const OBJECT_MODULE = `import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const Ticket = ObjectSchema.create({ + name: 'support_desk_ticket', + label: 'Ticket', + sharingModel: 'private', + fields: { + subject: Field.text({ label: 'Subject', required: true }), + status: Field.text({ label: 'Status' }), + priority: Field.text({ label: 'Priority' }), + }, +}); +`; + +const viewModule = (columns: string) => `import { defineView } from '@objectstack/spec/ui'; + +const data = { provider: 'object' as const, object: 'support_desk_ticket' }; + +export const TicketViews = defineView({ + list: { label: 'All Tickets', type: 'grid', data, columns: ${columns} }, +}); +`; + +const CONFIG = `import { defineStack } from '@objectstack/spec'; +import { Ticket } from './src/objects/ticket.object'; +import { TicketViews } from './src/views'; + +export default defineStack({ manifest: ${MANIFEST}, objects: [Ticket], views: [TicketViews] }); +`; + +let root = ''; + +/** A project directory: `objectstack.config.ts` plus `files`, with `@objectstack/spec` linked. */ +function project(label: string, files: Record): string { + const dir = join(root, label); + for (const [name, text] of Object.entries(files)) { + mkdirSync(join(dir, name, '..'), { recursive: true }); + writeFileSync(join(dir, name), text); + } + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: label, version: '0.0.0', private: true, type: 'module' })); + linkSpec(dir); + return join(dir, 'objectstack.config.ts'); +} + +const viewProject = (label: string, columns: string) => + project(label, { + 'objectstack.config.ts': CONFIG, + 'src/objects/ticket.object.ts': OBJECT_MODULE, + 'src/views/ticket.view.ts': viewModule(columns), + 'src/views/index.ts': `export { TicketViews } from './ticket.view';\n`, + }); + +/** What bundle-require itself throws for `config`: the message a non-Zod load error must keep. */ +async function bundleLoadMessage(config: string): Promise { + try { + await bundleRequire({ filepath: config, external: BUNDLE_REQUIRE_EXTERNALS }); + } catch (error) { + return (error as Error).message; + } + throw new Error(`expected ${config} to fail to load`); +} + +async function loadMessage(config: string): Promise { + try { + await loadConfig(config); + } catch (error) { + return (error as Error).message; + } + throw new Error(`expected ${config} to fail to load`); +} + +let mixed = ''; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-config-load-zod-refusal-')); + mixed = viewProject('mixed', `[{ field: 'subject', link: true }, 'status', 'priority']`); +}); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +const LOCATED_HEAD = /src\/views\/ticket\.view\.ts:5:28 · defineView validation failed \(1 issue\):/; + +// The first in-process command run loads oclif and the spec (~5 s on a shared box). +describe('a define* helper ZodError at config load is one located refusal', { timeout: 60_000 }, () => { + it('os validate --json: the error names the view module, line, helper and path; exit 1', async () => { + const r = await run(Validate, [mixed, '--json']); + expect(r.code).toBe(1); + const payload = JSON.parse(r.stdout) as { valid: boolean; error: string }; + expect(payload.valid).toBe(false); + expect(payload.error).toMatch(LOCATED_HEAD); + expect(payload.error).toContain('✗ list.columns: Invalid input'); + expect(payload.error).toContain('✗ list.columns.0:'); + expect(payload.error.trimStart().startsWith('[')).toBe(false); + }); + + it('os validate text face: no raw JSON array reaches the terminal', async () => { + const r = await run(Validate, [mixed]); + expect(r.code).toBe(1); + expect(r.stdout).toMatch(LOCATED_HEAD); + expect(r.stdout).not.toMatch(/✗ \[\s*$/m); + expect(r.stdout).not.toContain('"code": "invalid_union"'); + }); + + it('os build --json carries the same refusal as os validate, exit code unchanged', async () => { + const validate = JSON.parse((await run(Validate, [mixed, '--json'])).stdout) as { error: string }; + const out = join(root, 'mixed', 'dist', 'objectstack.json'); + const r = await run(Compile, [mixed, '--output', out, '--json']); + expect(r.code).not.toBe(0); + const payload = JSON.parse(r.stdout) as { error: string }; + expect(payload.error).toMatch(LOCATED_HEAD); + expect(payload.error).toBe(validate.error); + }); + + it('a call that is not a define* helper is located without naming a helper', async () => { + const config = project('object-create', { + 'objectstack.config.ts': `import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +const T = ObjectSchema.create({ name: 'BadName', label: 'T', fields: { a: Field.text({ label: 'A' }) } }); +export default defineStack({ manifest: ${MANIFEST}, objects: [T] }); +`, + }); + const message = await loadMessage(config); + expect(message).toMatch(/objectstack\.config\.ts:3:\d+ · Validation failed \(1 issue\):/); + expect(message).toContain('✗ name:'); + }); + + it('a stack the bundle frame is missing from names the config entry, never a guessed module', () => { + const parsed = z.object({ name: z.string() }).safeParse({ name: 1 }); + if (parsed.success) throw new Error('fixture parse unexpectedly succeeded'); + const error = parsed.error as unknown as Error & { issues: unknown[] }; + error.stack = 'ZodError: []\n at defineView (file:///elsewhere/spec/dist/index.mjs:1:1)'; + const config = join(root, 'mixed', 'objectstack.config.ts'); + const refusal = configLoadRefusal(error, config, undefined, join(root, 'mixed')); + expect(refusal.message).toMatch( + /^objectstack\.config\.ts \(loading it threw; the stack does not say which module\) · Validation failed \(1 issue\):/, + ); + expect(refusal.cause).toBe(error); + }); +}); + +describe('controls: everything else a load throws is unchanged', { timeout: 60_000 }, () => { + it('a config that loads cleanly still validates', async () => { + const clean = viewProject('clean', `[{ field: 'subject', link: true }, { field: 'status' }, { field: 'priority' }]`); + const r = await run(Validate, [clean, '--json']); + expect(r.code).toBe(0); + expect((JSON.parse(r.stdout) as { valid: boolean }).valid).toBe(true); + }); + + it('a module that does not resolve keeps the bundle load message', async () => { + const config = project('missing-import', { + 'objectstack.config.ts': `import { nothing } from './src/not-there';\nexport default nothing;\n`, + }); + expect(await loadMessage(config)).toBe(await bundleLoadMessage(config)); + }); + + it('a syntax error keeps the bundle load message', async () => { + const config = project('syntax', { 'objectstack.config.ts': `export default {\n` }); + expect(await loadMessage(config)).toBe(await bundleLoadMessage(config)); + }); + + it("a helper's own prose refusal keeps its words", async () => { + const config = project('no-views', { + 'objectstack.config.ts': `import { defineView } from '@objectstack/spec/ui';\nexport default defineView({});\n`, + }); + expect(await loadMessage(config)).toBe(await bundleLoadMessage(config)); + expect(await loadMessage(config)).toMatch(/^defineView: the container defines no views/); + }); +});