diff --git a/CHANGELOG.md b/CHANGELOG.md index b7915ed..d7ff2bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,17 @@ Pre-1.0 releases followed it in spirit; their breaking changes are marked **Brea 3.0.0 and appeared in no guide; the guide now has a Diagnostics section listing the events and what they are useful for. +- **A preflight failure now reports what it found as data.** `PreflightError` + carried its findings as a list of sentences; `problems` carries them as + `{ code, message, subject, moduleId, path }` — `COMMAND_HANDLER_CONFLICT`, + `SERVICE_CAPTIVE_DEPENDENCY`, `TRUST_REQUIRED` and the rest — so a test, a + CI step or an editor integration can branch on what went wrong instead of + matching prose. The service-graph validator and runtime preflight had these + codes all along and were dropping them at the throw. The sentence list stays + as `issues`, unchanged, and the error's message is the same text as before. + `PreflightError` is exported from the root, so the error can be recognised + with `instanceof` rather than by its name. + ## [4.0.1] - 2026-08-29 **A patch to the tree-view adapter, plus four corrections to what the project diff --git a/README.md b/README.md index 5842edc..7a4f9a3 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,7 @@ so treat the floor as a formality rather than a tested target. - **`ERR_REQUIRE_ESM` or `require() of ES Module`**: the package is ESM only, by design; bundle your extension with esbuild/webpack/rollup, which is what VS Code extensions normally do anyway - **`Symbol.dispose` or `AbortSignal` is not defined in the types**: add `ESNext.Disposable` and one of `DOM` / `WebWorker` / `@types/node` to `lib` — see [Platform Requirements](#platform-requirements) -- **An error at import time, before anything ran**: that is preflight, and it is working; the message names the duplicate id, the missing service or the cycle +- **An error at import time, before anything ran**: that is preflight, and it is working; the message names the duplicate id, the missing service or the cycle, and `problems` on the error carries each one as a code a script can act on - **A command is greyed out in the Command Palette**: that is `enablement` / `commandPalette` `when` in your `package.json`, not something this package controls - **A text editor command's result is `undefined`**: VS Code runs those handlers fire-and-forget and discards what they return; use `module.commands.handle` with `Editors.active` when the caller needs the result - **`vscode` cannot be resolved in tests**: point Vitest's `resolve.alias` at `@kkdev92/vscode-ext-kit/testing/vitest`, or merge `vscodeExtKitVitestConfig` diff --git a/docs/guide.md b/docs/guide.md index 8f6f011..09da69f 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -704,6 +704,59 @@ log, count or attach to a bug report — not an event bus. Delivery is best-effort by design: a listener that throws is ignored, and nothing waits for one. +### When preflight says no + +Preflight is the other place the framework tells you something went wrong, and +it does so by throwing: `defineExtension` at import time for a structural +problem, `activate` for a host that does not meet a module's requirements. The +error is a `PreflightError`, and it carries every problem it found rather than +the first — each with a stable `code`, the `subject` it is about and, where a +module declared it, the `moduleId`. + + + +```ts +import { PreflightError } from '@kkdev92/vscode-ext-kit'; + +/** + * Turns a preflight failure into lines a person can act on. + * + * `defineExtension` throws before VS Code is touched, with every problem it + * found rather than the first. Each problem carries a stable `code` — for a + * script or a test to branch on — and a `message` that says the same thing to + * a person. Anything else is rethrown untouched. + */ +export function explainPreflight(error: unknown): readonly string[] { + if (!(error instanceof PreflightError)) { + throw error; + } + return error.problems.map((problem) => + problem.moduleId === undefined + ? `${problem.code}: ${problem.message}` + : `${problem.code} in ${problem.moduleId}: ${problem.message}` + ); +} + +/** + * The one check a CI step usually wants: did the graph change shape? + * + * A captive dependency — a singleton holding a transient — is the kind of + * mistake that only shows up as a stale value weeks later. Preflight reports + * it at import time, and the code makes it a one-line gate. + */ +export function holdsATransientCaptive(error: unknown): boolean { + return ( + error instanceof PreflightError && + error.problems.some((problem) => problem.code === 'SERVICE_CAPTIVE_DEPENDENCY') + ); +} +``` + +The message still lists every problem as a sentence, so nothing changes for a +reader of the console. The codes are for everything else: a test that asserts a +plan is well-formed, a CI step, an editor integration. They are listed on +`compileApplication` and on `RuntimeIssue`. + `application.shutdownTimeout` deserves the special attention above because it is the one event that reports something the framework could not do. VS Code races every extension's deactivation against a few seconds and then exits; the diff --git a/docs/samples/preflight.ts b/docs/samples/preflight.ts new file mode 100644 index 0000000..30351e5 --- /dev/null +++ b/docs/samples/preflight.ts @@ -0,0 +1,34 @@ +import { PreflightError } from '@kkdev92/vscode-ext-kit'; + +/** + * Turns a preflight failure into lines a person can act on. + * + * `defineExtension` throws before VS Code is touched, with every problem it + * found rather than the first. Each problem carries a stable `code` — for a + * script or a test to branch on — and a `message` that says the same thing to + * a person. Anything else is rethrown untouched. + */ +export function explainPreflight(error: unknown): readonly string[] { + if (!(error instanceof PreflightError)) { + throw error; + } + return error.problems.map((problem) => + problem.moduleId === undefined + ? `${problem.code}: ${problem.message}` + : `${problem.code} in ${problem.moduleId}: ${problem.message}` + ); +} + +/** + * The one check a CI step usually wants: did the graph change shape? + * + * A captive dependency — a singleton holding a transient — is the kind of + * mistake that only shows up as a stale value weeks later. Preflight reports + * it at import time, and the code makes it a one-line gate. + */ +export function holdsATransientCaptive(error: unknown): boolean { + return ( + error instanceof PreflightError && + error.problems.some((problem) => problem.code === 'SERVICE_CAPTIVE_DEPENDENCY') + ); +} diff --git a/src/foundation/application/application.ts b/src/foundation/application/application.ts index 4234660..09fe0ff 100644 --- a/src/foundation/application/application.ts +++ b/src/foundation/application/application.ts @@ -488,7 +488,13 @@ export function createApplication(options: CreateApplicationOptions): Applicatio const errors = issues.filter((issue) => issue.severity === PreflightSeverity.Error); if (errors.length > 0) { - throw new PreflightError(errors.map((issue) => issue.message)); + throw new PreflightError( + errors.map((issue) => ({ + code: issue.code, + message: issue.message, + moduleId: issue.moduleId, + })) + ); } // Settings accessors are registered by the framework, not by a module, so diff --git a/src/foundation/application/plan.ts b/src/foundation/application/plan.ts index a35127a..f64d840 100644 --- a/src/foundation/application/plan.ts +++ b/src/foundation/application/plan.ts @@ -17,9 +17,11 @@ import type { CommandDefinition, TextEditorCommandDefinition } from '../commands/definition.js'; import type { HostedServiceDefinition } from '../hosted-services/definition.js'; import { PreflightError } from '../internal/errors.js'; +import type { PreflightProblem } from '../internal/errors.js'; import type { ModuleDefinition } from '../modules/definition.js'; import type { ServiceDescriptor } from '../services/descriptors.js'; import { validateServiceGraph } from '../services/graph.js'; +import type { ServiceGraphIssueCode } from '../services/graph.js'; import type { ServiceMap, ServiceToken } from '../services/token.js'; import type { RawRegistrationDefinition } from '../raw/definition.js'; import type { SettingsRegistration } from '../settings/definition.js'; @@ -120,6 +122,17 @@ export const FRAMEWORK_SERVICES: readonly ServiceToken[] = Object.freez Log, ]); +/** + * The service-graph validator names its findings in its own vocabulary; here + * they join the rest of preflight's, so a reader of `problems` sees one. + */ +const SERVICE_GRAPH_CODES: Readonly> = { + 'duplicate-token': 'SERVICE_DUPLICATE', + 'missing-dependency': 'SERVICE_MISSING_DEPENDENCY', + 'circular-dependency': 'SERVICE_CIRCULAR_DEPENDENCY', + 'captive-dependency': 'SERVICE_CAPTIVE_DEPENDENCY', +}; + /** * Compiles modules into an immutable plan, reporting every definition-time * problem it can find before a single platform registration happens. @@ -132,7 +145,25 @@ export const FRAMEWORK_SERVICES: readonly ServiceToken[] = Object.freez * flattened plan collection; it does not mutate caller-owned definitions. * * @throws {@link PreflightError} listing every problem found. No partial plan - * is returned and no module callback is run by this function. + * is returned and no module callback is run by this function. Each problem + * carries one of these codes: + * + * - `MODULE_DUPLICATE` — a module appears twice in the list + * - `COMMAND_HANDLER_CONFLICT` — two handlers for one command id + * - `HOSTED_SERVICE_DUPLICATE`, `HOSTED_SERVICE_EMPTY` — a hosted service id + * declared twice, or one declaring neither `start`, `run` nor `stop` + * - `SETTINGS_SECTION_DUPLICATE`, `STORAGE_KEY_DUPLICATE`, `SECRET_KEY_DUPLICATE` + * - `STORAGE_SYNCABLE_WORKSPACE` — `syncable` on a workspace-scoped key, + * which VS Code never syncs + * - `FILE_WATCHER_DUPLICATE`, `STATUS_BAR_ITEM_DUPLICATE`, + * `LANGUAGE_STATUS_ITEM_DUPLICATE`, `TREE_VIEW_DUPLICATE`, + * `WEBVIEW_VIEW_DUPLICATE`, `WEBVIEW_RESTORER_DUPLICATE`, + * `RAW_REGISTRATION_DUPLICATE` + * - `SERVICE_DUPLICATE`, `SERVICE_MISSING_DEPENDENCY`, + * `SERVICE_CIRCULAR_DEPENDENCY`, `SERVICE_CAPTIVE_DEPENDENCY` — the service + * graph; a cycle and a captive dependency carry the `path` they were found on + * - `DEPENDENCY_UNREGISTERED` — a command, hosted service, watcher, view or + * raw registration injects a token nothing registers * * @example * ```ts @@ -143,12 +174,17 @@ export const FRAMEWORK_SERVICES: readonly ServiceToken[] = Object.freez * ``` */ export function compileApplication(options: CompileApplicationOptions): ApplicationPlan { - const issues: string[] = []; + const problems: PreflightProblem[] = []; const moduleIds = new Set(); for (const module of options.modules) { if (moduleIds.has(module.id)) { - issues.push(`Module "${module.id}" is registered more than once.`); + problems.push({ + code: 'MODULE_DUPLICATE', + subject: module.id, + moduleId: module.id, + message: `Module "${module.id}" is registered more than once.`, + }); continue; } moduleIds.add(module.id); @@ -172,7 +208,12 @@ export function compileApplication(options: CompileApplicationOptions): Applicat const watcherIds = new Set(); for (const watcher of fileWatchers) { if (watcherIds.has(watcher.id)) { - issues.push(`File watcher "${watcher.id}" is registered more than once.`); + problems.push({ + code: 'FILE_WATCHER_DUPLICATE', + subject: watcher.id, + moduleId: watcher.moduleId, + message: `File watcher "${watcher.id}" is registered more than once.`, + }); continue; } watcherIds.add(watcher.id); @@ -181,7 +222,11 @@ export function compileApplication(options: CompileApplicationOptions): Applicat const statusBarIds = new Set(); for (const item of statusBarItems) { if (statusBarIds.has(item.id)) { - issues.push(`Status bar item "${item.id}" is registered more than once.`); + problems.push({ + code: 'STATUS_BAR_ITEM_DUPLICATE', + subject: item.id, + message: `Status bar item "${item.id}" is registered more than once.`, + }); continue; } statusBarIds.add(item.id); @@ -190,7 +235,11 @@ export function compileApplication(options: CompileApplicationOptions): Applicat const languageStatusIds = new Set(); for (const item of languageStatusItems) { if (languageStatusIds.has(item.id)) { - issues.push(`Language status item "${item.id}" is registered more than once.`); + problems.push({ + code: 'LANGUAGE_STATUS_ITEM_DUPLICATE', + subject: item.id, + message: `Language status item "${item.id}" is registered more than once.`, + }); continue; } languageStatusIds.add(item.id); @@ -199,7 +248,12 @@ export function compileApplication(options: CompileApplicationOptions): Applicat const treeViewIds = new Set(); for (const view of treeViews) { if (treeViewIds.has(view.id)) { - issues.push(`Tree view "${view.id}" is registered more than once.`); + problems.push({ + code: 'TREE_VIEW_DUPLICATE', + subject: view.id, + moduleId: view.moduleId, + message: `Tree view "${view.id}" is registered more than once.`, + }); continue; } treeViewIds.add(view.id); @@ -208,7 +262,12 @@ export function compileApplication(options: CompileApplicationOptions): Applicat const serializerTypes = new Set(); for (const serializer of webviewSerializers) { if (serializerTypes.has(serializer.viewType)) { - issues.push(`Webview panel restorer "${serializer.viewType}" is registered more than once.`); + problems.push({ + code: 'WEBVIEW_RESTORER_DUPLICATE', + subject: serializer.viewType, + moduleId: serializer.moduleId, + message: `Webview panel restorer "${serializer.viewType}" is registered more than once.`, + }); continue; } serializerTypes.add(serializer.viewType); @@ -217,7 +276,12 @@ export function compileApplication(options: CompileApplicationOptions): Applicat const webviewViewIds = new Set(); for (const view of webviewViews) { if (webviewViewIds.has(view.id)) { - issues.push(`Webview view "${view.id}" is registered more than once.`); + problems.push({ + code: 'WEBVIEW_VIEW_DUPLICATE', + subject: view.id, + moduleId: view.moduleId, + message: `Webview view "${view.id}" is registered more than once.`, + }); continue; } webviewViewIds.add(view.id); @@ -227,25 +291,34 @@ export function compileApplication(options: CompileApplicationOptions): Applicat for (const registration of storage) { const id = `${registration.scope}:${registration.key}`; if (storageIds.has(id)) { - issues.push( - `Storage key "${registration.key}" (${registration.scope}) is registered more than once.` - ); + problems.push({ + code: 'STORAGE_KEY_DUPLICATE', + subject: registration.key, + message: `Storage key "${registration.key}" (${registration.scope}) is registered more than once.`, + }); continue; } storageIds.add(id); if (registration.syncable === true && registration.scope !== 'global') { - issues.push( - `Storage key "${registration.key}" declares syncable but is workspace-scoped; ` + - 'workspaceState is never synced.' - ); + problems.push({ + code: 'STORAGE_SYNCABLE_WORKSPACE', + subject: registration.key, + message: + `Storage key "${registration.key}" declares syncable but is workspace-scoped; ` + + 'workspaceState is never synced.', + }); } } const secretIds = new Set(); for (const registration of secrets) { if (secretIds.has(registration.key)) { - issues.push(`Secret key "${registration.key}" is registered more than once.`); + problems.push({ + code: 'SECRET_KEY_DUPLICATE', + subject: registration.key, + message: `Secret key "${registration.key}" is registered more than once.`, + }); continue; } secretIds.add(registration.key); @@ -254,7 +327,12 @@ export function compileApplication(options: CompileApplicationOptions): Applicat const rawIds = new Set(); for (const registration of rawRegistrations) { if (rawIds.has(registration.id)) { - issues.push(`Raw registration "${registration.id}" is registered more than once.`); + problems.push({ + code: 'RAW_REGISTRATION_DUPLICATE', + subject: registration.id, + moduleId: registration.moduleId, + message: `Raw registration "${registration.id}" is registered more than once.`, + }); continue; } rawIds.add(registration.id); @@ -263,7 +341,11 @@ export function compileApplication(options: CompileApplicationOptions): Applicat const settingsSections = new Set(); for (const registration of settings) { if (settingsSections.has(registration.section)) { - issues.push(`Settings section "${registration.section}" is registered more than once.`); + problems.push({ + code: 'SETTINGS_SECTION_DUPLICATE', + subject: registration.section, + message: `Settings section "${registration.section}" is registered more than once.`, + }); continue; } settingsSections.add(registration.section); @@ -276,10 +358,14 @@ export function compileApplication(options: CompileApplicationOptions): Applicat const id = command.contract.descriptor.id; const existing = commandOwners.get(id); if (existing !== undefined) { - issues.push( - `Command "${id}" has handlers in both "${existing}" and "${command.moduleId}". ` + - 'VS Code allows only one handler per command id.' - ); + problems.push({ + code: 'COMMAND_HANDLER_CONFLICT', + subject: id, + moduleId: command.moduleId, + message: + `Command "${id}" has handlers in both "${existing}" and "${command.moduleId}". ` + + 'VS Code allows only one handler per command id.', + }); continue; } commandOwners.set(id, command.moduleId); @@ -289,10 +375,14 @@ export function compileApplication(options: CompileApplicationOptions): Applicat for (const hostedService of hostedServices) { const existing = hostedServiceOwners.get(hostedService.id); if (existing !== undefined) { - issues.push( - `Hosted service "${hostedService.id}" is registered in both "${existing}" and ` + - `"${hostedService.moduleId}".` - ); + problems.push({ + code: 'HOSTED_SERVICE_DUPLICATE', + subject: hostedService.id, + moduleId: hostedService.moduleId, + message: + `Hosted service "${hostedService.id}" is registered in both "${existing}" and ` + + `"${hostedService.moduleId}".`, + }); continue; } hostedServiceOwners.set(hostedService.id, hostedService.moduleId); @@ -302,7 +392,12 @@ export function compileApplication(options: CompileApplicationOptions): Applicat hostedService.run === undefined && hostedService.stop === undefined ) { - issues.push(`Hosted service "${hostedService.id}" declares no start, run or stop.`); + problems.push({ + code: 'HOSTED_SERVICE_EMPTY', + subject: hostedService.id, + moduleId: hostedService.moduleId, + message: `Hosted service "${hostedService.id}" declares no start, run or stop.`, + }); } } @@ -329,7 +424,13 @@ export function compileApplication(options: CompileApplicationOptions): Applicat // declared storage or a framework token is ordinary, and the module-registered // descriptors alone cannot see either. for (const issue of validateServiceGraph(services, { provided: registered })) { - issues.push(issue.message); + problems.push({ + code: SERVICE_GRAPH_CODES[issue.code], + subject: issue.tokenId, + moduleId: issue.moduleId, + path: issue.path, + message: issue.message, + }); } // Commands, hosted services and the rest take dependencies too, and those @@ -337,10 +438,14 @@ export function compileApplication(options: CompileApplicationOptions): Applicat const checkDependencies = (dependencies: ServiceMap, owner: string, moduleId: string): void => { for (const [name, token] of Object.entries(dependencies)) { if (!registered.has(token)) { - issues.push( - `${owner} in module "${moduleId}" depends on "${token.id}" as "${name}", ` + - 'but nothing registers that token.' - ); + problems.push({ + code: 'DEPENDENCY_UNREGISTERED', + subject: token.id, + moduleId, + message: + `${owner} in module "${moduleId}" depends on "${token.id}" as "${name}", ` + + 'but nothing registers that token.', + }); } } }; @@ -383,8 +488,8 @@ export function compileApplication(options: CompileApplicationOptions): Applicat ); } - if (issues.length > 0) { - throw new PreflightError(issues); + if (problems.length > 0) { + throw new PreflightError(problems); } return Object.freeze({ diff --git a/src/foundation/application/runtime-preflight.ts b/src/foundation/application/runtime-preflight.ts index a85e2bc..0922bd6 100644 --- a/src/foundation/application/runtime-preflight.ts +++ b/src/foundation/application/runtime-preflight.ts @@ -22,7 +22,13 @@ export type PreflightSeverity = (typeof PreflightSeverity)[keyof typeof Prefligh /** One runtime-preflight finding associated with a Module. */ export interface RuntimeIssue { readonly severity: PreflightSeverity; - /** Stable, machine-readable code. */ + /** + * Stable, machine-readable code. + * + * Errors: `WORKSPACE_REQUIRED`, `TRUST_REQUIRED`, `LOCAL_FILESYSTEM_REQUIRED`, + * `NODE_MODULE_IN_WEB_HOST`. Warnings: `COMPATIBILITY_UNSPECIFIED_IN_WEB_HOST`, + * `UI_PREFERRED_ON_REMOTE`. + */ readonly code: string; readonly message: string; /** Module the finding belongs to. */ @@ -43,7 +49,7 @@ export interface RuntimeIssue { * const issues = runtimePreflight(plan, environment.read()); * const errors = issues.filter((issue) => issue.severity === 'error'); * if (errors.length > 0) { - * throw new PreflightError(errors.map((issue) => issue.message)); + * throw new PreflightError(errors); * } * ``` * diff --git a/src/foundation/internal/errors.ts b/src/foundation/internal/errors.ts index 746518a..2f3626f 100644 --- a/src/foundation/internal/errors.ts +++ b/src/foundation/internal/errors.ts @@ -90,30 +90,58 @@ export class ServiceResolutionError extends Error { } } +/** + * One thing preflight found wrong, as data. + * + * `code` is stable and meant for a program; `message` says the same thing to a + * person. `subject` names what the problem is about — a module, a command, a + * token, a storage key — and `moduleId` the module that declared it, when one + * did. `path` is the dependency chain, for a cycle or a captive dependency. + * + * The codes `compileApplication` reports are listed on that function; the ones + * `runtimePreflight` reports are listed on `RuntimeIssue`. + */ +export interface PreflightProblem { + readonly code: string; + readonly message: string; + readonly subject?: string | undefined; + readonly moduleId?: string | undefined; + readonly path?: readonly string[] | undefined; +} + /** * Thrown when preflight rejects an application before any VS Code registration * happens. Carries every problem found, not just the first. * + * `problems` is the structured form. `issues` is the same list as messages, in + * the same order, for code that only wants to print them. + * * @example * ```ts * try { * compileApplication({ name: 'sample', modules }); * } catch (error) { * if (error instanceof PreflightError) { - * for (const issue of error.issues) console.error(issue); + * for (const problem of error.problems) { + * console.error(`${problem.code}: ${problem.message}`); + * } * } * } * ``` */ export class PreflightError extends Error { /** Every problem found, in the order detected. */ + readonly problems: readonly PreflightProblem[]; + /** Each problem's message, in the same order. */ readonly issues: readonly string[]; - constructor(issues: readonly string[]) { + constructor(problems: readonly PreflightProblem[]) { + const issues = problems.map((problem) => problem.message); super( `Application preflight failed with ${issues.length} problem(s):\n- ${issues.join('\n- ')}` ); this.name = 'PreflightError'; + this.problems = problems; this.issues = issues; } } diff --git a/src/foundation/services/graph.ts b/src/foundation/services/graph.ts index c12d718..7bbd924 100644 --- a/src/foundation/services/graph.ts +++ b/src/foundation/services/graph.ts @@ -43,7 +43,9 @@ export interface ServiceGraphIssue { * ```ts * const issues = validateServiceGraph(descriptors, { provided: frameworkTokens }); * if (issues.length > 0) { - * throw new PreflightError(issues.map((issue) => issue.message)); + * throw new PreflightError( + * issues.map(({ code, message, moduleId }) => ({ code, message, moduleId })) + * ); * } * ``` */ diff --git a/src/index.ts b/src/index.ts index ef80e4f..8f21323 100644 --- a/src/index.ts +++ b/src/index.ts @@ -319,6 +319,11 @@ export { validationError, } from './foundation/operations/errors.js'; export type { FrameworkErrorOptions } from './foundation/operations/errors.js'; +// Thrown by `defineExtension` at import time and by `activate` when the host +// fails a requirement. Exported so it can be recognised with `instanceof` and +// its `problems` read as data rather than parsed out of the message. +export { PreflightError } from './foundation/internal/errors.js'; +export type { PreflightProblem } from './foundation/internal/errors.js'; export { OperationCancelledError } from './foundation/operations/cancellation.js'; export { RetryExhaustedError } from './capabilities/std/retry.js'; export { TimeoutError } from './capabilities/std/timing.js'; diff --git a/tests/foundation/application/runtime-preflight.test.ts b/tests/foundation/application/runtime-preflight.test.ts index 09c0f5c..b2952fc 100644 --- a/tests/foundation/application/runtime-preflight.test.ts +++ b/tests/foundation/application/runtime-preflight.test.ts @@ -163,7 +163,14 @@ describe('runtime preflight during activation', () => { environment: { isTrusted: false }, }); - await expect(host.start()).rejects.toBeInstanceOf(PreflightError); + const failure: unknown = await host.start().catch((error: unknown) => error); + expect(failure).toBeInstanceOf(PreflightError); + // The code the environment check produced survives the throw, so a caller + // can tell an untrusted workspace from a missing folder without reading + // the sentence. + expect((failure as PreflightError).problems).toEqual([ + expect.objectContaining({ code: 'TRUST_REQUIRED', moduleId: expect.any(String) }), + ]); // The point of running before binding: nothing was registered. expect(host.commands.registeredIds).toEqual([]); diff --git a/tests/foundation/services/services-and-plan.test.ts b/tests/foundation/services/services-and-plan.test.ts index 78cdcde..bcfdc01 100644 --- a/tests/foundation/services/services-and-plan.test.ts +++ b/tests/foundation/services/services-and-plan.test.ts @@ -328,6 +328,74 @@ describe('compileApplication', () => { expect(issues).toHaveLength(2); expect(issues.join('\n')).toContain('only one handler per command id'); expect(issues.join('\n')).toContain('core.clock'); + + // The same findings as data: a code to branch on, the id it is about, and + // the module that declared it. + const problems = (caught as PreflightError).problems; + expect(problems).toEqual([ + expect.objectContaining({ + code: 'COMMAND_HANDLER_CONFLICT', + subject: 'sample.refresh', + moduleId: 'b', + }), + expect.objectContaining({ + code: 'DEPENDENCY_UNREGISTERED', + subject: 'core.clock', + moduleId: 'b', + }), + ]); + // `issues` is `problems` reduced to its messages, in the same order. + expect(issues).toEqual(problems.map((problem) => problem.message)); + }); + + it('gives every kind of problem a stable code', () => { + // Two declarations of one key, the first of them asking to sync a + // workspace-scoped value; a hosted service with no lifecycle; a singleton + // holding a transient. + const Recent = defineStorage({ + key: 'recent', + scope: 'workspace', + syncable: true, + defaultValue: '', + }); + const RecentAgain = defineStorage({ + key: 'recent', + scope: 'workspace', + defaultValue: '', + }); + const module = defineModule('projects', (builder): undefined => { + builder.services.singleton(Repository, { + inject: { clock: Clock }, + create: ({ clock }) => ({ clock }), + }); + builder.services.transient(Clock, () => ({ now: () => 0 })); + builder.storage.add(Recent); + builder.storage.add(RecentAgain); + builder.hostedServices.add({ id: 'projects.empty' }); + return undefined; + }); + + let caught: unknown; + try { + compileApplication({ name: 'sample', modules: [module] }); + } catch (error) { + caught = error; + } + + const problems = (caught as PreflightError).problems; + expect(problems.map((problem) => problem.code)).toEqual([ + 'STORAGE_SYNCABLE_WORKSPACE', + 'STORAGE_KEY_DUPLICATE', + 'HOSTED_SERVICE_EMPTY', + 'SERVICE_CAPTIVE_DEPENDENCY', + ]); + // A graph problem keeps the path the validator walked, which is what tells + // the reader *where* in the graph the singleton meets the transient. + expect(problems[3]).toMatchObject({ + subject: 'projects.repository', + moduleId: 'projects', + path: ['projects.repository', 'core.clock'], + }); }); it('rejects a duplicate module id', () => {