diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/cdk-config.ts b/packages/@aws-cdk/cdk-explorer/lib/core/cdk-config.ts new file mode 100644 index 000000000..edcffd7e6 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/core/cdk-config.ts @@ -0,0 +1,35 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * The subset of `cdk.json` the explorer cares about. + * + * `app` is the command CDK runs to produce a cloud assembly (e.g. + * `npx ts-node bin/app.ts`). We need it to invoke `Toolkit.synth()` + * via `fromCdkApp`. + */ +export interface CdkConfig { + /** The `app` command, or `undefined` if missing/malformed. */ + readonly app: string | undefined; +} + +/** + * Reads `/cdk.json` and returns the parts the explorer uses. + * Never throws. Treats missing files, malformed JSON, or wrong-typed + * fields as "not configured" so callers can fall back gracefully + */ +export function readCdkConfig(projectDir: string): CdkConfig { + const configPath = path.join(projectDir, 'cdk.json'); + if (!fs.existsSync(configPath)) return { app: undefined }; + + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + return { app: undefined }; + } + + if (parsed === null || typeof parsed !== 'object') return { app: undefined }; + const app = (parsed as { app?: unknown }).app; + return { app: typeof app === 'string' ? app : undefined }; +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts new file mode 100644 index 000000000..0624fd4c6 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts @@ -0,0 +1,85 @@ +import { ToolkitError, type Toolkit } from '@aws-cdk/toolkit-lib'; +import { readCdkConfig } from './cdk-config'; + +/** + * The outcome of a single synth attempt. + * + * `success` means the assembly was written to disk (the watcher will see it). + * `app-failure` means the user's CDK app threw, did not compile, or needs uncached context lookups. + * `lock-conflict` means another process holds `/cdk.out` (a `cdk + * synth` running in a terminal, a `cdk watch` loop, or our own previous synth + * not yet released). Callers should not surface this as a hard error. + * `unavailable` means `cdk.json` is missing or has no `app` key, so there is + * nothing to synth. Read fresh on every call, so adding an `app` later is + * picked up without restarting the LSP. + * `error` is reserved for anything we did not classify, including failures + * during dispose. + */ +export type SynthRunResult = + | { status: 'success' } + | { status: 'app-failure'; message: string; details?: string } + | { status: 'lock-conflict' } + | { status: 'unavailable' } + | { status: 'error'; message: string }; + +export interface SynthRunnerOptions { + /** A configured Toolkit instance (its IoHost decides where messages go). */ + readonly toolkit: Toolkit; + /** Directory containing the user's `cdk.json`; also the synth working dir. */ + readonly projectDir: string; +} + +/** + * Run a one-shot synth of the user's CDK app. Writes `/cdk.out` + * via `Toolkit.synth(fromCdkApp(...))`, then immediately disposes the cached + * assembly so the read lock is released before the next call. Holding the + * cached assembly between calls would cause the next acquireWrite to throw + * `ConcurrentReadLock` against ourselves. + * + * The `app` command is read from `cdk.json` on every call, not cached, so an + * edited command or a newly added `app` takes effect on the next synth. + */ +export async function runSynth(options: SynthRunnerOptions): Promise { + const app = readCdkConfig(options.projectDir).app; + if (app === undefined) return { status: 'unavailable' }; + + let cached; + try { + const cx = await options.toolkit.fromCdkApp(app, { + workingDirectory: options.projectDir, + lookups: false, + }); + cached = await options.toolkit.synth(cx); + } catch (err) { + return classify(err); + } + + try { + await cached.dispose(); + } catch (err) { + // Releases the read lock synth() left on the assembly. Failure is rare (an + // fs error deleting the lock file); report it as `error` so the next synth + // does not silently self-conflict on the stale reader. + return { status: 'error', message: (err as Error).message }; + } + + return { status: 'success' }; +} + +function classify(err: unknown): SynthRunResult { + if (ToolkitError.isLockError(err)) { + return { status: 'lock-conflict' }; + } + if (ToolkitError.isContextLookupsDisabledError(err)) { + return { + status: 'app-failure', + message: 'This app needs context lookups that are not in cdk.context.json. ' + + 'Run `cdk synth` in a terminal (with AWS credentials) to populate it, then retry.', + }; + } + if (ToolkitError.isAssemblyError(err)) { + // details = captured subprocess stderr (file:line:col), used for diagnostics. + return { status: 'app-failure', message: err.message, details: (err.cause as Error | undefined)?.message }; + } + return { status: 'error', message: (err as Error).message }; +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts index bd07de5c0..5a22bda48 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts @@ -1,6 +1,7 @@ import { pathToFileURL } from 'url'; import type { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; import { type CodeLens, type Command, type Range } from 'vscode-languageserver/node'; +import { COMMAND_DISABLE_AUTO_SYNTH, COMMAND_ENABLE_AUTO_SYNTH, COMMAND_SYNTH_NOW } from './commands'; import { resourceTarget, type ResourceTarget } from './template-locator'; import type { ConstructNode } from '../core/assembly-reader'; import type { SourceLocation } from '../core/source-resolver'; @@ -9,21 +10,44 @@ import type { SourceLocation } from '../core/source-resolver'; export const OPEN_RESOURCE_COMMAND = 'cdkExplorer.openResource'; /** - * Build CodeLens entries for a single source file. For every construct whose - * sourceLocation matches fileUri, group by line and emit one lens per line - * summarising the CFN resources produced there. + * Build CodeLens entries for a single source file. Returns an empty array if + * no CDK resources in the index map to `fileUri`. + * + * When resources are found, two header lenses are prepended at line 0: + * - `autoSynthEnabled = false`: "↻ Synth now" + "▶ Enable auto-synth" + * - `autoSynthEnabled = true`: "⏹ Disable auto-synth" (saves trigger synth) + * + * The remaining lenses are one per source line, each summarising the CFN + * resources produced there (multiple L2 fan-out resources are grouped). + * + * @param autoSynthEnabled - current toggle state; controls which header lenses appear */ -export function codeLensesForFile(index: ConstructIndex, fileUri: string): CodeLens[] { +export function codeLensesForFile( + index: ConstructIndex, + fileUri: string, + autoSynthEnabled: boolean, +): CodeLens[] { const matches = [...index] .filter((node) => isResourceOnFile(node, fileUri)) .map((node) => ({ line: node.sourceLocation.line, node })); - // Multiple resources can map to one line when an L2 construct fans out - // (e.g. an L2 producing a primary resource + auxiliary resources). - return [...groupBy(matches, (m) => m.line)].map(([line, group]) => ({ + const l1Lenses = [...groupBy(matches, (m) => m.line)].map(([line, group]) => ({ range: lineRange(line), command: commandFor(group.map((m) => m.node)), })); + + if (l1Lenses.length === 0) return []; + + const header0: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; + // When auto-synth is off, show "Synth now" + "Enable auto-synth". + // When auto-synth is on, show only "Disable auto-synth" (saves handle synth). + const headerLenses: CodeLens[] = autoSynthEnabled + ? [{ range: header0, command: { title: '⏹ Disable auto-synth', command: COMMAND_DISABLE_AUTO_SYNTH } }] + : [ + { range: header0, command: { title: '↻ Synth now', command: COMMAND_SYNTH_NOW } }, + { range: header0, command: { title: '▶ Enable auto-synth', command: COMMAND_ENABLE_AUTO_SYNTH } }, + ]; + return [...headerLenses, ...l1Lenses]; } /** diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts new file mode 100644 index 000000000..bb89f0a3a --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts @@ -0,0 +1,94 @@ +import type { SynthRunResult } from '../core/synth-runner'; + +/** + * Trigger a one-shot synth of the user's CDK app. Only shown when + * auto-synth is disabled (saves handle synth when it is enabled). + */ +export const COMMAND_SYNTH_NOW = 'cdk.explorer.synthNow'; +/** Enable auto-synth-on-save. Replaces "Synth now" in the header lens. */ +export const COMMAND_ENABLE_AUTO_SYNTH = 'cdk.explorer.enableAutoSynth'; +/** Disable auto-synth-on-save. Restores the "Synth now" header lens. */ +export const COMMAND_DISABLE_AUTO_SYNTH = 'cdk.explorer.disableAutoSynth'; + +/** All commands this LSP advertises via `executeCommandProvider`. */ +export const SUPPORTED_COMMANDS = [COMMAND_SYNTH_NOW, COMMAND_ENABLE_AUTO_SYNTH, COMMAND_DISABLE_AUTO_SYNTH] as const; + +/** + * UI sinks the dispatcher uses to communicate with the user. + * Implementations bridge to `connection.console` in the LSP layer. + */ +export interface NotifySink { + /** Write a non-error informational message to the Output panel. */ + info(message: string): void; + /** Write an error message to the Output panel. */ + error(message: string): void; + /** + * Run a long operation with a visible progress indicator. The implementation + * is responsible for ending the indicator regardless of success or failure. + */ + withProgress(message: string, fn: () => Promise): Promise; +} + +export interface CommandHandlerOptions { + /** Invokes a single synth. Resolves with the typed outcome; never rejects. */ + readonly synth: () => Promise; + /** Called with the new desired state when the user toggles auto-synth. */ + readonly toggleAutoSynth: (enabled: boolean) => void; + /** UI sinks for messages and progress. */ + readonly notify: NotifySink; +} + +const SYNTH_UNAVAILABLE_MESSAGE = "CDK synth unavailable: 'cdk.json' missing or has no 'app' key."; +const LOCK_CONFLICT_MESSAGE = 'Another synth is in progress. Results will refresh shortly.'; +const PROGRESS_MESSAGE = 'Synthesizing CDK app...'; + +/** + * Handle a `workspace/executeCommand` request. The synth command runs under a + * progress indicator and reports outcomes through the notify sinks. Unknown + * commands are silently ignored. + */ +export async function executeCommand( + command: string, + _args: unknown[], + options: CommandHandlerOptions, +): Promise { + switch (command) { + case COMMAND_ENABLE_AUTO_SYNTH: + options.toggleAutoSynth(true); + return; + + case COMMAND_DISABLE_AUTO_SYNTH: + options.toggleAutoSynth(false); + return; + + case COMMAND_SYNTH_NOW: + { + const result = await options.notify.withProgress(PROGRESS_MESSAGE, () => options.synth()); + handleSynthResult(result, options.notify); + } + return; + + default: + return; + } +} + +function handleSynthResult(result: SynthRunResult, notify: NotifySink): void { + switch (result.status) { + case 'success': + // Silent. The watcher refreshes the editor when `cdk.out` changes. + return; + case 'app-failure': + notify.error(`CDK synth failed: ${result.message}`); + return; + case 'lock-conflict': + notify.info(LOCK_CONFLICT_MESSAGE); + return; + case 'unavailable': + notify.info(SYNTH_UNAVAILABLE_MESSAGE); + return; + case 'error': + notify.error(`CDK synth failed unexpectedly: ${result.message}`); + return; + } +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts new file mode 100644 index 000000000..2ea1d1991 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts @@ -0,0 +1,46 @@ +import type { IIoHost, IoMessage, IoRequest } from '@aws-cdk/toolkit-lib'; +import type { RemoteConsole } from 'vscode-languageserver/node'; + +/** + * IoHost for the LSP. Routes Toolkit messages into the editor's Output + * channel via the LSP connection's console. + * + * We do NOT reuse `NonInteractiveIoHost` from toolkit-lib even though its + * `requestResponse` is identical: that class writes to `process.stdout` / + * `process.stderr`, which are the JSON-RPC transport for this process. + * Writing Toolkit output there would corrupt the protocol stream. + * + * The LSP cannot prompt the user synchronously through `connection.console`, + * so `requestResponse` returns each message's `defaultResponse`. For synth, + * the only reachable interactive prompt is an MFA token (when the app has + * context lookups, no cached `cdk.context.json`, and an MFA-protected profile). + * Returning the default causes an auth failure, surfaced as `app-failure` with + * a clear message. All other prompts are on deploy/destroy paths we don't call. + */ +export class LspIoHost implements IIoHost { + public constructor(private readonly console: RemoteConsole) { + } + + public async notify(msg: IoMessage): Promise { + switch (msg.level) { + case 'error': + this.console.error(msg.message); + break; + case 'warn': + this.console.warn(msg.message); + break; + case 'debug': + case 'trace': + // Suppress noisy levels; keeps the Output panel readable. + break; + default: + this.console.info(msg.message); + } + } + + public async requestResponse(msg: IoRequest): Promise { + await this.notify(msg); + this.console.info(`Auto-answered with default response: ${JSON.stringify(msg.defaultResponse)}`); + return msg.defaultResponse; + } +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts index f1f09461f..2e058262f 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts @@ -1,9 +1,21 @@ +import { Toolkit } from '@aws-cdk/toolkit-lib'; +import { LspIoHost } from './io-host'; import { startServer } from './server'; +import { runSynth } from '../core/synth-runner'; try { startServer({ readable: process.stdin, writable: process.stdout, + // synthRunnerFactory: startServer invokes it once, after the LSP connection + // exists, so the runner it returns can route Toolkit output to the editor's + // Output panel via connection.console. The handler passes the resolved + // project root on each call; the runner reads that project's cdk.json `app` + // per synth, so it is always built and "no app" is reported per call. + synthRunnerFactory: (console) => { + const toolkit = new Toolkit({ ioHost: new LspIoHost(console) }); + return (projectDir) => runSynth({ toolkit, projectDir }); + }, }); } catch (err) { const e = err as Error; diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index 04b24259f..b466773e8 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -16,14 +16,18 @@ import { type DefinitionParams, type DidSaveTextDocumentParams, type Diagnostic, + type ExecuteCommandParams, type InitializeParams, type InitializeResult, type Location, + type RemoteConsole, } from 'vscode-languageserver/node'; /* eslint-disable import/no-relative-packages */ import { codeLensesForFile } from './codelens'; +import { executeCommand, SUPPORTED_COMMANDS, type NotifySink } from './commands'; import { mapViolationsToDiagnostics } from './diagnostics'; import { offsetAtPosition } from './positions'; +import { synthFailureDiagnostics } from './synth-diagnostics'; import { sourceTargetAtTemplateOffset } from './template-locator'; import { WATCH_EXCLUDE_DEFAULTS } from '../../../toolkit-lib/lib/actions/watch/private/helpers'; import { createIgnoreMatcher } from '../../../toolkit-lib/lib/util/glob-matcher'; @@ -37,10 +41,9 @@ import { type AssemblyWatcher, type AssemblyWatcherOptions, } from '../core/assembly-watcher'; +import type { SynthRunResult } from '../core/synth-runner'; export interface LspHandlerOptions { - /** Callback invoked on `didSave` for tracked source files. */ - readonly onSynthRequest?: (projectDir: string) => void; /** Override readAssembly for tests. Defaults to reading /cdk.out. */ readonly readAssembly?: (assemblyDir: string) => AssemblyReadResult; /** @@ -61,11 +64,30 @@ export interface LspHandlerOptions { * overridden in tests to drive refreshes deterministically. */ readonly startAssemblyWatcher?: (options: AssemblyWatcherOptions) => AssemblyWatcher; + /** + * Runs a synth of the project at the given root and returns its typed outcome. + * Injected by startServer (built from `synthRunnerFactory`); omitted in tests + * that don't exercise synth. The runner reads `cdk.json` under the passed root + * on each call and returns `unavailable` when there is no `app`, so + * availability is decided per synth, not cached here. + */ + readonly synthRunner?: (projectDir: string) => Promise; + /** User-facing notification sink. Injected by startServer; omitted in tests. */ + readonly notify?: NotifySink; } -export interface LspServerOptions extends LspHandlerOptions { +/** Builds the synth runner once `connection.console` is available (in startServer). */ +export type SynthRunnerFactory = (console: RemoteConsole) => ((projectDir: string) => Promise); + +export interface LspServerOptions { readonly readable: NodeJS.ReadableStream; readonly writable: NodeJS.WritableStream; + /** + * Factory for the synth runner, invoked once in `startServer` when + * `connection.console` first exists. The console-free core + * (`createLspHandlers`) consumes the built `synthRunner` it returns. + */ + readonly synthRunnerFactory?: SynthRunnerFactory; } /** Pure handler functions for LSP messages, extracted for direct unit testing. */ @@ -75,6 +97,7 @@ export interface LspHandlers { onDidSaveTextDocument(params: DidSaveTextDocumentParams): void; onCodeLens(params: CodeLensParams): CodeLens[]; onDefinition(params: DefinitionParams): Location | undefined; + onExecuteCommand(params: ExecuteCommandParams): Promise; onShutdown(): void; } @@ -90,13 +113,27 @@ const NOOP_LOGGER: LogSink = { }, }; +/** Log auto-synth-on-save outcomes. Errors go to the Output panel; success is silent. */ +function handleSynthOnSave(result: SynthRunResult, log: LogSink): void { + switch (result.status) { + case 'success': + case 'lock-conflict': + case 'unavailable': + return; // silent — watcher handles updates; lock = another synth running; unavailable = no app + case 'app-failure': + log.error(`Auto-synth failed: ${result.message}`); + return; + case 'error': + log.error(`Auto-synth failed unexpectedly: ${result.message}`); + return; + } +} + /** * Build the LSP message handlers as plain functions over closed-over state. * No streams, no JSON-RPC, no framework — testable in isolation. */ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers { - const onSynthRequest = options.onSynthRequest ?? (() => { - }); const readAssembly = options.readAssembly ?? defaultReadAssembly; const log = options.logger ?? NOOP_LOGGER; const onPublishDiagnostics = options.onPublishDiagnostics ?? (() => { @@ -104,9 +141,21 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers const onRefreshCodeLenses = options.onRefreshCodeLenses ?? (() => { }); const startWatcher = options.startAssemblyWatcher ?? defaultStartAssemblyWatcher; + const synthRunner = options.synthRunner; + const notify = options.notify ?? { + info: () => { + }, + error: () => { + }, + withProgress: (_msg: string, fn: () => Promise) => fn(), + }; let applicationDir: string | undefined; let shutdownRequested = false; + let synthInFlight = false; + // URIs (source files, or cdk.json) currently showing synth-failure diagnostics. + let synthFailureUris = new Set(); + let autoSynthEnabled = false; // off by default; user enables via the CodeLens toggle let shouldIgnore: (filePath: string) => boolean = () => false; let assemblyWatcher: AssemblyWatcher | undefined; // Latest index from readAssembly, served to CodeLens. Refreshed at startup @@ -121,6 +170,14 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers // the editor's next natural re-query. let codeLensRefreshSupported = false; + // Single source of truth for the project root: the directory the client opened + // (applicationDir from initialize), falling back to cwd for non-IDE callers. + // Every consumer (assembly read, watcher, synth, diagnostics) reads this so + // they never disagree about which project is being operated on. + function currentProjectDir(): string { + return applicationDir ?? process.cwd(); + } + function refreshFromAssembly(projectDir: string): void { const assemblyDir = path.join(projectDir, 'cdk.out'); const result = readAssembly(assemblyDir); @@ -163,6 +220,52 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers } } + // Shared synth invocation used by both the manual CodeLens command and + // auto-synth-on-save. The in-flight latch suppresses overlapping synths from + // the same LSP instance: a second call while the first is running is dropped + // (not queued) and returns lock-conflict immediately. The Toolkit's RWLock + // would reject it anyway, but this short-circuits before any setup work. + async function guardedSynth(): Promise { + if (synthInFlight) return { status: 'lock-conflict' }; + synthInFlight = true; + try { + const result = await (synthRunner ? synthRunner(currentProjectDir()) : Promise.resolve({ status: 'error', message: 'No synth runner configured' } as const)); + publishSynthDiagnostics(result); + return result; + } finally { + synthInFlight = false; + } + } + + // Publish diagnostics for a failed synth (one per failing source file, or + // cdk.json as a fallback) and clear them once a synth succeeds. The cdk.out + // watcher owns violation diagnostics; this only manages synth-failure ones. + function publishSynthDiagnostics(result: SynthRunResult): void { + // A successful synth resolves all failures; 'unavailable' means there is no + // app to fail, so any prior synth-failure diagnostics no longer apply. + if (result.status === 'success' || result.status === 'unavailable') { + clearSynthFailures(); + return; + } + const failures = synthFailureDiagnostics(result, currentProjectDir()); + if (failures.length === 0) return; // lock-conflict / error: leave any existing diagnostic + const nextUris = new Set(failures.map((f) => f.uri)); + for (const uri of synthFailureUris) { + if (!nextUris.has(uri)) onPublishDiagnostics(uri, []); + } + for (const f of failures) { + onPublishDiagnostics(f.uri, f.diagnostics); + } + synthFailureUris = nextUris; + } + + function clearSynthFailures(): void { + for (const uri of synthFailureUris) { + onPublishDiagnostics(uri, []); + } + synthFailureUris = new Set(); + } + return { onInitialize(params) { applicationDir = params.initializationOptions?.applicationDir; @@ -180,11 +283,12 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers codeLensProvider: { resolveProvider: false }, // Go-to-definition from a synthesized template back to construct source. definitionProvider: true, + executeCommandProvider: { commands: [...SUPPORTED_COMMANDS] }, }, }; }, onInitialized() { - const projectDir = applicationDir ?? process.cwd(); + const projectDir = currentProjectDir(); // Same exclusion logic as toolkit-lib's watch(): // WATCH_EXCLUDE_DEFAULTS covers common non-source dirs, then we add cdk.out // (our own output) and dotfiles (editor configs, .git, etc.) @@ -213,15 +317,13 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers if (shutdownRequested) return; const filePath = fileURLToPath(params.textDocument.uri); if (shouldIgnore(filePath)) return; - const projectDir = applicationDir ?? process.cwd(); - try { - onSynthRequest(projectDir); - } catch (err) { - log.error(`Synth request failed: ${(err as Error).message}`); - } + if (!autoSynthEnabled) return; + void guardedSynth() + .then((result) => handleSynthOnSave(result, log)) + .catch((err: unknown) => log.error(`Auto-synth threw unexpectedly: ${(err as Error).message}`)); }, onCodeLens(params) { - return codeLensesForFile(cachedIndex, params.textDocument.uri); + return codeLensesForFile(cachedIndex, params.textDocument.uri, autoSynthEnabled); }, onDefinition(params) { // Only synthesized templates link back to source, and only file: URIs are @@ -244,6 +346,16 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers const offset = offsetAtPosition(templateText, params.position); return sourceTargetAtTemplateOffset(cachedIndex, filePath, templateText, offset); }, + async onExecuteCommand(params) { + await executeCommand(params.command, params.arguments ?? [], { + synth: guardedSynth, + toggleAutoSynth: (enabled) => { + autoSynthEnabled = enabled; + onRefreshCodeLenses(); + }, + notify, + }); + }, onShutdown() { shutdownRequested = true; void assemblyWatcher?.close(); @@ -259,23 +371,53 @@ export function startServer(options: LspServerOptions): void { new StreamMessageWriter(options.writable), ); + // Captured from onInitialize; used to gate CodeLens refresh requests. + let codeLensRefreshSupported = false; + const handlers = createLspHandlers({ - onSynthRequest: options.onSynthRequest, - readAssembly: options.readAssembly, logger: connection.console, onPublishDiagnostics: (uri, diagnostics) => { void connection.sendDiagnostics({ uri, diagnostics }); }, onRefreshCodeLenses: () => { - void connection.sendRequest(CodeLensRefreshRequest.type); + // codeLensRefreshSupported is captured from onInitialize; gate here so + // the toggle and the watcher both respect the client's capability. + if (codeLensRefreshSupported) { + void connection.sendRequest(CodeLensRefreshRequest.type); + } + }, + synthRunner: options.synthRunnerFactory?.(connection.console), + notify: { + // Route to the Output panel (connection.console) rather than popups. + // showMessage creates a dismissable toast that interrupts the user's + // workflow; console writes are visible on demand in the Output panel. + info: (msg) => { + connection.console.info(msg); + }, + error: (msg) => { + connection.console.error(msg); + }, + withProgress: async (title, fn) => { + const progress = await connection.window.createWorkDoneProgress(); + progress.begin(title); + try { + return await fn(); + } finally { + progress.done(); + } + }, }, }); - connection.onInitialize((params) => handlers.onInitialize(params)); + connection.onInitialize((params) => { + codeLensRefreshSupported = params.capabilities.workspace?.codeLens?.refreshSupport ?? false; + return handlers.onInitialize(params); + }); connection.onInitialized(() => handlers.onInitialized()); connection.onDidSaveTextDocument((params) => handlers.onDidSaveTextDocument(params)); connection.onCodeLens((params) => handlers.onCodeLens(params)); connection.onDefinition((params) => handlers.onDefinition(params)); + connection.onExecuteCommand((params) => handlers.onExecuteCommand(params)); connection.onShutdown(() => handlers.onShutdown()); connection.onExit(() => process.exit(0)); diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/synth-diagnostics.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/synth-diagnostics.ts new file mode 100644 index 000000000..23c59dc6c --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/synth-diagnostics.ts @@ -0,0 +1,87 @@ +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { type Diagnostic, DiagnosticSeverity, type Range } from 'vscode-languageserver/node'; +import type { SynthRunResult } from '../core/synth-runner'; + +/** A compile error parsed from synth stderr. Line/column are 1-based. */ +export interface SynthErrorLocation { + readonly file: string; + readonly line: number; + readonly column: number; + readonly message: string; +} + +// ts-node "pretty": "bin/app.ts:12:5 - error TS2322: ..." +const TS_NODE_RE = /^(.+?):(\d+):(\d+) - (error TS\d+:.+)$/gm; +// tsc / ts-node default: "bin/app.ts(12,5): error TS2322: ..." +const TSC_RE = /^(.+?)\((\d+),(\d+)\): (error TS\d+:.+)$/gm; + +/** + * Find every TypeScript compile error (file:line:col) in synth stderr. + * Returns an empty array when nothing matches (e.g. a non-TypeScript app or a + * runtime failure). + */ +export function parseSynthErrors(stderr: string | undefined): SynthErrorLocation[] { + if (!stderr) return []; + const out: SynthErrorLocation[] = []; + for (const re of [TS_NODE_RE, TSC_RE]) { + for (const m of stderr.matchAll(re)) { + out.push({ file: m[1], line: Number(m[2]), column: Number(m[3]), message: m[4] }); + } + } + return out; +} + +/** A diagnostic set ready to publish, with the URI it belongs to. */ +export interface SynthFailureDiagnostic { + readonly uri: string; + readonly diagnostics: Diagnostic[]; +} + +/** + * Build diagnostics for an app-failure synth outcome: one entry per failing + * source file, considering only files inside `projectDir`. When nothing anchors + * there, falls back to a single diagnostic on `cdk.json` carrying the summary + * message. Returns an empty array for any non-app-failure outcome. + */ +export function synthFailureDiagnostics( + result: SynthRunResult, + projectDir: string, +): SynthFailureDiagnostic[] { + if (result.status !== 'app-failure') return []; + + const byUri = new Map(); + for (const loc of parseSynthErrors(result.details)) { + const abs = path.isAbsolute(loc.file) ? loc.file : path.resolve(projectDir, loc.file); + if (!isWithin(projectDir, abs)) continue; // never point diagnostics outside the project + const uri = pathToFileURL(abs).toString(); + const list = byUri.get(uri) ?? []; + list.push(diagnostic(rangeAt(loc.line, loc.column), loc.message)); + byUri.set(uri, list); + } + + if (byUri.size > 0) { + return [...byUri].map(([uri, diagnostics]) => ({ uri, diagnostics })); + } + + return [{ + uri: pathToFileURL(path.join(projectDir, 'cdk.json')).toString(), + diagnostics: [diagnostic(rangeAt(1, 1), result.message)], + }]; +} + +function diagnostic(range: Range, message: string): Diagnostic { + return { range, severity: DiagnosticSeverity.Error, source: 'cdk synth', message }; +} + +/** LSP positions are 0-based; the parsed line/column are 1-based. */ +function rangeAt(line: number, column: number): Range { + const l = Math.max(0, line - 1); + const c = Math.max(0, column - 1); + return { start: { line: l, character: c }, end: { line: l, character: Number.MAX_VALUE } }; +} + +function isWithin(root: string, candidate: string): boolean { + const rel = path.relative(root, candidate); + return rel.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel); +} diff --git a/packages/@aws-cdk/cdk-explorer/test/core/cdk-config.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/cdk-config.test.ts new file mode 100644 index 000000000..55b009c62 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/core/cdk-config.test.ts @@ -0,0 +1,60 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { readCdkConfig } from '../../lib/core/cdk-config'; + +function withTempDir(fn: (dir: string) => T): T { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-explorer-cdkconfig-')); + try { + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function writeCdkJson(dir: string, contents: string): void { + fs.writeFileSync(path.join(dir, 'cdk.json'), contents); +} + +describe('readCdkConfig', () => { + test('returns the app command when present', () => { + withTempDir((dir) => { + writeCdkJson(dir, JSON.stringify({ app: 'npx ts-node bin/app.ts' })); + expect(readCdkConfig(dir)).toEqual({ app: 'npx ts-node bin/app.ts' }); + }); + }); + + test('returns undefined when cdk.json is absent', () => { + withTempDir((dir) => { + expect(readCdkConfig(dir)).toEqual({ app: undefined }); + }); + }); + + test('returns undefined when cdk.json is malformed', () => { + withTempDir((dir) => { + writeCdkJson(dir, '{not valid json'); + expect(readCdkConfig(dir)).toEqual({ app: undefined }); + }); + }); + + test('returns undefined when the app key is missing', () => { + withTempDir((dir) => { + writeCdkJson(dir, JSON.stringify({ context: {} })); + expect(readCdkConfig(dir)).toEqual({ app: undefined }); + }); + }); + + test('returns undefined when the app value is not a string', () => { + withTempDir((dir) => { + writeCdkJson(dir, JSON.stringify({ app: 42 })); + expect(readCdkConfig(dir)).toEqual({ app: undefined }); + }); + }); + + test('returns undefined when cdk.json contains a JSON null', () => { + withTempDir((dir) => { + writeCdkJson(dir, 'null'); + expect(readCdkConfig(dir)).toEqual({ app: undefined }); + }); + }); +}); diff --git a/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts b/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts new file mode 100644 index 000000000..8981348da --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/core/synth-runner.test.ts @@ -0,0 +1,162 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { AssemblyError, ContextLookupsDisabledError, LockError, ToolkitError, type Toolkit } from '@aws-cdk/toolkit-lib'; +import { runSynth } from '../../lib/core/synth-runner'; + +const APP = 'npx ts-node bin/app.ts'; + +interface FakeCachedAssembly { + dispose: jest.Mock; +} + +interface FakeToolkit { + fromCdkApp: jest.Mock; + synth: jest.Mock; +} + +function makeToolkit(opts: { + synthThrow?: unknown; + disposeThrow?: unknown; +}): { toolkit: FakeToolkit; cached: FakeCachedAssembly } { + const cached: FakeCachedAssembly = { + dispose: jest.fn().mockImplementation(() => + opts.disposeThrow ? Promise.reject(opts.disposeThrow) : Promise.resolve(), + ), + }; + const toolkit: FakeToolkit = { + fromCdkApp: jest.fn().mockResolvedValue({}), + synth: jest.fn().mockImplementation(() => + opts.synthThrow ? Promise.reject(opts.synthThrow) : Promise.resolve(cached), + ), + }; + return { toolkit, cached }; +} + +const tempDirs: string[] = []; + +// Create a throwaway project dir. Pass an object to write its cdk.json +// (`{ app }` for a configured app, `{}` for a cdk.json with no app), or +// `undefined` for no cdk.json at all. runSynth reads the app from this on disk. +function makeProjectDir(cdkJson?: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-explorer-synth-')); + tempDirs.push(dir); + if (cdkJson !== undefined) { + fs.writeFileSync(path.join(dir, 'cdk.json'), JSON.stringify(cdkJson)); + } + return dir; +} + +afterAll(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function run(toolkit: FakeToolkit, projectDir: string) { + return runSynth({ toolkit: toolkit as unknown as Toolkit, projectDir }); +} + +describe('runSynth', () => { + test('reads the app from cdk.json, returns success, and disposes the cached assembly', async () => { + const { toolkit, cached } = makeToolkit({}); + const projectDir = makeProjectDir({ app: APP }); + + const result = await run(toolkit, projectDir); + + expect(result).toEqual({ status: 'success' }); + expect(toolkit.fromCdkApp).toHaveBeenCalledWith(APP, { workingDirectory: projectDir, lookups: false }); + expect(cached.dispose).toHaveBeenCalledTimes(1); + }); + + test('returns unavailable (without invoking the toolkit) when cdk.json has no app', async () => { + const { toolkit } = makeToolkit({}); + const projectDir = makeProjectDir({}); + + const result = await run(toolkit, projectDir); + + expect(result).toEqual({ status: 'unavailable' }); + expect(toolkit.fromCdkApp).not.toHaveBeenCalled(); + }); + + test('returns unavailable when cdk.json is missing entirely', async () => { + const { toolkit } = makeToolkit({}); + const projectDir = makeProjectDir(undefined); + + const result = await run(toolkit, projectDir); + + expect(result).toEqual({ status: 'unavailable' }); + expect(toolkit.fromCdkApp).not.toHaveBeenCalled(); + }); + + test('classifies AssemblyError as app-failure with the error message', async () => { + const { toolkit } = makeToolkit({ + synthThrow: AssemblyError.withCause('Assembly builder failed', new Error('TypeError: foo')), + }); + + const result = await run(toolkit, makeProjectDir({ app: APP })); + + expect(result).toEqual({ status: 'app-failure', message: expect.stringContaining('Assembly builder failed'), details: 'TypeError: foo' }); + }); + + test('classifies ConcurrentWriteLock as lock-conflict', async () => { + const { toolkit } = makeToolkit({ + synthThrow: new LockError('ConcurrentWriteLock', 'another CLI synthing'), + }); + + const result = await run(toolkit, makeProjectDir({ app: APP })); + + expect(result).toEqual({ status: 'lock-conflict' }); + }); + + test('classifies ConcurrentReadLock as lock-conflict', async () => { + const { toolkit } = makeToolkit({ + synthThrow: new LockError('ConcurrentReadLock', 'another CLI reading'), + }); + + const result = await run(toolkit, makeProjectDir({ app: APP })); + + expect(result).toEqual({ status: 'lock-conflict' }); + }); + + test('classifies ContextLookupsDisabledError as app-failure with the error message', async () => { + const { toolkit } = makeToolkit({ + synthThrow: new ContextLookupsDisabledError('Context lookups have been disabled. Run cdk synth in a terminal.'), + }); + + const result = await run(toolkit, makeProjectDir({ app: APP })); + + expect(result).toEqual({ status: 'app-failure', message: expect.stringContaining('cdk.context.json') }); + }); + + test('classifies an unknown ToolkitError as error', async () => { + const { toolkit } = makeToolkit({ + synthThrow: new ToolkitError('SomeUnexpected', 'unexpected'), + }); + + const result = await run(toolkit, makeProjectDir({ app: APP })); + + expect(result).toEqual({ status: 'error', message: 'unexpected' }); + }); + + test('classifies a plain Error as error', async () => { + const { toolkit } = makeToolkit({ + synthThrow: new Error('disk full'), + }); + + const result = await run(toolkit, makeProjectDir({ app: APP })); + + expect(result).toEqual({ status: 'error', message: 'disk full' }); + }); + + test('returns error when dispose fails after a successful synth', async () => { + const { toolkit, cached } = makeToolkit({ + disposeThrow: new Error('lock release failed'), + }); + + const result = await run(toolkit, makeProjectDir({ app: APP })); + + expect(result).toEqual({ status: 'error', message: 'lock release failed' }); + expect(cached.dispose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts index c33e0c597..c724b18db 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from 'url'; import { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; import type { ConstructNode } from '../../lib'; import { codeLensesForFile, OPEN_RESOURCE_COMMAND } from '../../lib/lsp/codelens'; +import { COMMAND_SYNTH_NOW, COMMAND_ENABLE_AUTO_SYNTH, COMMAND_DISABLE_AUTO_SYNTH } from '../../lib/lsp/commands'; const FILE = '/p/lib/stack.ts'; const URI = pathToFileURL(FILE).toString(); @@ -25,7 +26,7 @@ interface CommandChoice { describe('codeLensesForFile', () => { test('returns no lenses when tree is empty', () => { - expect(codeLensesForFile(ConstructIndex.fromTree([]), URI)).toEqual([]); + expect(codeLensesForFile(ConstructIndex.fromTree([]), URI, false)).toEqual([]); }); test('returns no lenses for non-resource wrapper nodes (no logicalId)', () => { @@ -36,7 +37,7 @@ describe('codeLensesForFile', () => { sourceLocation: { file: FILE, line: 12, column: 5 }, // logicalId/type intentionally omitted })]; - expect(codeLensesForFile(ConstructIndex.fromTree(tree), URI)).toEqual([]); + expect(codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)).toEqual([]); }); test('emits one lens per resource on its source line', () => { @@ -47,13 +48,13 @@ describe('codeLensesForFile', () => { sourceLocation: { file: FILE, line: 12, column: 5 }, })]; - const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(1); - expect(lenses[0].range).toEqual({ + const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false); + expect(lenses).toHaveLength(3); // 2 header + 1 L1 + expect(lenses[2].range).toEqual({ start: { line: 11, character: 0 }, end: { line: 11, character: 0 }, }); - expect(lenses[0].command?.title).toBe('Creates AWS::S3::Bucket'); + expect(lenses[2].command?.title).toBe('Creates AWS::S3::Bucket'); }); test('groups multiple resources on the same source line into one lens', () => { @@ -80,9 +81,9 @@ describe('codeLensesForFile', () => { }), ]; - const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(1); - expect(lenses[0].command?.title).toBe('Creates 3 resources: AWS::S3::Bucket, AWS::S3::BucketPolicy, AWS::KMS::Key'); + const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false); + expect(lenses).toHaveLength(3); // 2 header + 1 grouped L1 + expect(lenses[2].command?.title).toBe('Creates 3 resources: AWS::S3::Bucket, AWS::S3::BucketPolicy, AWS::KMS::Key'); }); test('emits separate lenses for resources on different lines', () => { @@ -101,9 +102,9 @@ describe('codeLensesForFile', () => { }), ]; - const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(2); - expect(lenses.map((l) => l.range.start.line).sort((a, b) => a - b)).toEqual([9, 19]); + const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false); + expect(lenses).toHaveLength(4); // 2 header + 2 L1 + expect(lenses.slice(2).map((l) => l.range.start.line).sort((a, b) => a - b)).toEqual([9, 19]); }); test('filters out resources from other files', () => { @@ -123,14 +124,15 @@ describe('codeLensesForFile', () => { ]; const index = ConstructIndex.fromTree(tree); - // Each query returns only the resource defined in that file, which proves the - // URI filter selects by file rather than returning everything for any query. - const onThisFile = codeLensesForFile(index, URI); - expect(onThisFile).toHaveLength(1); - expect(onThisFile[0].command?.title).toBe('Creates AWS::S3::Bucket'); - const onOtherFile = codeLensesForFile(index, OTHER_URI); - expect(onOtherFile).toHaveLength(1); - expect(onOtherFile[0].command?.title).toBe('Creates AWS::SQS::Queue'); + // Each query returns 2 header lenses plus only the resource defined in + // that file, which proves the URI filter selects by file rather than + // returning everything for any query. + const onThisFile = codeLensesForFile(index, URI, false); + expect(onThisFile).toHaveLength(3); // 2 header + 1 L1 + expect(onThisFile[2].command?.title).toBe('Creates AWS::S3::Bucket'); + const onOtherFile = codeLensesForFile(index, OTHER_URI, false); + expect(onOtherFile).toHaveLength(3); // 2 header + 1 L1 + expect(onOtherFile[2].command?.title).toBe('Creates AWS::SQS::Queue'); }); test('walks descendants — finds resources nested under wrappers', () => { @@ -155,9 +157,9 @@ describe('codeLensesForFile', () => { }), ]; - const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI); - expect(lenses).toHaveLength(1); - expect(lenses[0].command?.title).toContain('AWS::S3::Bucket'); + const lenses = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false); + expect(lenses).toHaveLength(3); // 2 header + 1 + expect(lenses[2].command?.title).toContain('AWS::S3::Bucket'); }); test('omits resources without sourceLocation (non-TS apps)', () => { @@ -168,7 +170,7 @@ describe('codeLensesForFile', () => { // sourceLocation omitted — non-TS app })]; - expect(codeLensesForFile(ConstructIndex.fromTree(tree), URI)).toEqual([]); + expect(codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)).toEqual([]); }); test('a single resource with a resolvable template gets a clickable openResource command', () => { @@ -184,7 +186,7 @@ describe('codeLensesForFile', () => { sourceLocation: { file: FILE, line: 12, column: 5 }, })]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[0]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)[2]; expect(lens.command?.command).toBe(OPEN_RESOURCE_COMMAND); const choices = (lens.command!.arguments as CommandChoice[][])[0]; expect(choices).toHaveLength(1); @@ -212,7 +214,7 @@ describe('codeLensesForFile', () => { node({ path: 'Stack1/B/Policy', logicalId: 'B2', type: 'AWS::S3::BucketPolicy', templateFile, sourceLocation: { file: FILE, line: 12, column: 5 } }), ]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[0]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)[2]; const uri = pathToFileURL(templateFile).toString(); expect(lens.command?.command).toBe(OPEN_RESOURCE_COMMAND); const choices = (lens.command!.arguments as CommandChoice[][])[0]; @@ -235,7 +237,7 @@ describe('codeLensesForFile', () => { node({ path: 'Stack1/B/Policy', logicalId: 'B2', type: 'AWS::S3::BucketPolicy', templateFile: '/no/such.json', sourceLocation: { file: FILE, line: 12, column: 5 } }), ]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[0]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)[2]; expect(lens.command?.command).toBe(''); expect(lens.command?.arguments).toBeUndefined(); }); @@ -253,10 +255,35 @@ describe('codeLensesForFile', () => { sourceLocation: { file: FILE, line: 12, column: 5 }, })]; - const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[0]; + const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false)[2]; expect(lens.command?.command).toBe(''); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); + + test('header lens appears at line 0 with synthNow command when L1 lenses are present', () => { + const tree = [node({ + path: 'Stack1/MyBucket/Resource', + logicalId: 'MyBucketF68F3FF0', + type: 'AWS::S3::Bucket', + sourceLocation: { file: FILE, line: 12, column: 5 }, + })]; + + // auto-synth off: Synth now + Enable auto-synth + L1 + const lensesOff = codeLensesForFile(ConstructIndex.fromTree(tree), URI, false); + expect(lensesOff[0].range.start.line).toBe(0); + expect(lensesOff[0].command?.command).toBe(COMMAND_SYNTH_NOW); + expect(lensesOff[1].command?.command).toBe(COMMAND_ENABLE_AUTO_SYNTH); + + // auto-synth on: Disable auto-synth + L1 (no Synth now) + const lensesOn = codeLensesForFile(ConstructIndex.fromTree(tree), URI, true); + expect(lensesOn).toHaveLength(2); // 1 header + 1 L1 + expect(lensesOn[0].command?.command).toBe(COMMAND_DISABLE_AUTO_SYNTH); + }); + + test('no header lenses on files with no L1 lenses', () => { + // File has no CDK resources → no lenses at all, including no header lenses + expect(codeLensesForFile(ConstructIndex.fromTree([]), URI, false)).toEqual([]); + }); }); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts new file mode 100644 index 000000000..787af03f7 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/commands.test.ts @@ -0,0 +1,122 @@ +import type { SynthRunResult } from '../../lib/core/synth-runner'; +import { + COMMAND_SYNTH_NOW, + COMMAND_ENABLE_AUTO_SYNTH, + COMMAND_DISABLE_AUTO_SYNTH, + executeCommand, + type CommandHandlerOptions, + type NotifySink, +} from '../../lib/lsp/commands'; + +interface CapturedNotify extends NotifySink { + info: jest.Mock; + error: jest.Mock; + withProgress: jest.Mock; + progressMessages: string[]; +} + +function createNotify(): CapturedNotify { + const progressMessages: string[] = []; + const info = jest.fn(); + const error = jest.fn(); + const withProgress = jest.fn(async (message: string, fn: () => Promise) => { + progressMessages.push(message); + return fn(); + }); + return { info, error, withProgress, progressMessages }; +} + +function makeOptions(overrides: Partial = {}): { + options: CommandHandlerOptions; + notify: CapturedNotify; + synth: jest.Mock; + toggleAutoSynth: jest.Mock; +} { + const notify = createNotify(); + const synth = jest.fn(async () => ({ status: 'success' } as SynthRunResult)); + const toggleAutoSynth = jest.fn(); + return { + notify, + synth, + toggleAutoSynth, + options: { synth, toggleAutoSynth, notify, ...overrides }, + }; +} + +describe('executeCommand', () => { + test('enableAutoSynth calls toggleAutoSynth(true)', async () => { + const { options, toggleAutoSynth } = makeOptions(); + await executeCommand(COMMAND_ENABLE_AUTO_SYNTH, [], options); + expect(toggleAutoSynth).toHaveBeenCalledWith(true); + }); + + test('disableAutoSynth calls toggleAutoSynth(false)', async () => { + const { options, toggleAutoSynth } = makeOptions(); + await executeCommand(COMMAND_DISABLE_AUTO_SYNTH, [], options); + expect(toggleAutoSynth).toHaveBeenCalledWith(false); + }); + test('synthNow surfaces an unavailable result as an info notification', async () => { + const synth = jest.fn(async () => ({ status: 'unavailable' } as SynthRunResult)); + const { options, notify } = makeOptions({ synth }); + + await executeCommand(COMMAND_SYNTH_NOW, [], options); + + expect(synth).toHaveBeenCalledTimes(1); + expect(notify.info).toHaveBeenCalledWith(expect.stringContaining('cdk.json')); + expect(notify.error).not.toHaveBeenCalled(); + }); + + test('synthNow runs synth under withProgress on success and notifies nothing', async () => { + const { options, notify, synth } = makeOptions(); + + await executeCommand(COMMAND_SYNTH_NOW, [], options); + + expect(synth).toHaveBeenCalledTimes(1); + expect(notify.progressMessages).toEqual([expect.stringContaining('Synthesizing')]); + expect(notify.info).not.toHaveBeenCalled(); + expect(notify.error).not.toHaveBeenCalled(); + }); + + test('synthNow surfaces app-failure as an error notification', async () => { + const { options, notify } = makeOptions({ + synth: jest.fn(async () => ({ status: 'app-failure', message: 'TypeError: x' })), + }); + + await executeCommand(COMMAND_SYNTH_NOW, [], options); + + expect(notify.error).toHaveBeenCalledWith(expect.stringContaining('TypeError: x')); + expect(notify.info).not.toHaveBeenCalled(); + }); + + test('synthNow surfaces lock-conflict as an info notification', async () => { + const { options, notify } = makeOptions({ + synth: jest.fn(async () => ({ status: 'lock-conflict' })), + }); + + await executeCommand(COMMAND_SYNTH_NOW, [], options); + + expect(notify.info).toHaveBeenCalledWith(expect.stringContaining('in progress')); + expect(notify.error).not.toHaveBeenCalled(); + }); + + test('synthNow surfaces a generic error as an error notification', async () => { + const { options, notify } = makeOptions({ + synth: jest.fn(async () => ({ status: 'error', message: 'disk full' })), + }); + + await executeCommand(COMMAND_SYNTH_NOW, [], options); + + expect(notify.error).toHaveBeenCalledWith(expect.stringContaining('disk full')); + expect(notify.info).not.toHaveBeenCalled(); + }); + + test('unknown commands are silently ignored', async () => { + const { options, notify, synth } = makeOptions(); + + await executeCommand('cdk.explorer.bogus', [], options); + + expect(synth).not.toHaveBeenCalled(); + expect(notify.info).not.toHaveBeenCalled(); + expect(notify.error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/io-host.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/io-host.test.ts new file mode 100644 index 000000000..eb34d6f23 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/io-host.test.ts @@ -0,0 +1,61 @@ +import type { IoMessage, IoRequest } from '@aws-cdk/toolkit-lib'; +import type { RemoteConsole } from 'vscode-languageserver/node'; +import { LspIoHost } from '../../lib/lsp/io-host'; + +function makeConsole(): jest.Mocked> { + return { error: jest.fn(), warn: jest.fn(), info: jest.fn() }; +} + +function msg(level: string, message: string): IoMessage { + return { level, message, action: 'test', code: 'TEST_001', data: undefined, time: new Date() } as unknown as IoMessage; +} + +describe('LspIoHost', () => { + test('routes error level to console.error', async () => { + const console = makeConsole(); + const host = new LspIoHost(console as unknown as RemoteConsole); + await host.notify(msg('error', 'something broke')); + expect(console.error).toHaveBeenCalledWith('something broke'); + expect(console.warn).not.toHaveBeenCalled(); + expect(console.info).not.toHaveBeenCalled(); + }); + + test('routes warn level to console.warn', async () => { + const console = makeConsole(); + const host = new LspIoHost(console as unknown as RemoteConsole); + await host.notify(msg('warn', 'a warning')); + expect(console.warn).toHaveBeenCalledWith('a warning'); + }); + + test('routes info and default levels to console.info', async () => { + const console = makeConsole(); + const host = new LspIoHost(console as unknown as RemoteConsole); + await host.notify(msg('info', 'an info')); + await host.notify(msg('result', 'a result')); + expect(console.info).toHaveBeenCalledTimes(2); + }); + + test('suppresses debug and trace levels', async () => { + const console = makeConsole(); + const host = new LspIoHost(console as unknown as RemoteConsole); + await host.notify(msg('debug', 'verbose')); + await host.notify(msg('trace', 'very verbose')); + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + expect(console.info).not.toHaveBeenCalled(); + }); + + test('requestResponse returns defaultResponse without blocking', async () => { + const console = makeConsole(); + const host = new LspIoHost(console as unknown as RemoteConsole); + const request = { + ...msg('info', 'MFA token?'), + defaultResponse: 'default-token', + } as unknown as IoRequest; + + const result = await host.requestResponse(request); + + expect(result).toBe('default-token'); + expect(console.info).toHaveBeenCalledWith(expect.stringContaining('default-token')); + }); +}); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts index 86da656ff..313e7aebf 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts @@ -5,34 +5,44 @@ import { pathToFileURL } from 'url'; import type { Diagnostic, InitializeParams } from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; import type { AssemblyReadResult } from '../../lib'; +import type { SynthRunResult } from '../../lib/core/synth-runner'; +import { COMMAND_SYNTH_NOW, type NotifySink } from '../../lib/lsp/commands'; import { createLspHandlers, type LspHandlerOptions, type LspHandlers } from '../../lib/lsp/server'; +function makeNotifySink(): NotifySink & { infoMessages: string[]; errorMessages: string[] } { + const infoMessages: string[] = []; + const errorMessages: string[] = []; + return { + infoMessages, + errorMessages, + info: (msg) => infoMessages.push(msg), + error: (msg) => errorMessages.push(msg), + withProgress: async (_msg, fn) => fn(), + }; +} + interface CapturedClient { handlers: LspHandlers; published: Array<{ uri: string; diagnostics: Diagnostic[] }>; log: { warn: jest.Mock; error: jest.Mock }; refreshCodeLens: jest.Mock; watcherClosed: jest.Mock; - /** Fire the cdk.out watcher's onChange, as a real re-synth would. */ triggerWatcher: () => void; } -function createTestClient(opts?: Partial>): CapturedClient { +function createTestClient(opts?: Partial): CapturedClient { const published: Array<{ uri: string; diagnostics: Diagnostic[] }> = []; const log = { warn: jest.fn(), error: jest.fn() }; const refreshCodeLens = jest.fn(); const watcherClosed = jest.fn(); let watcherOnChange: (() => void) | undefined; const handlers = createLspHandlers({ - onSynthRequest: opts?.onSynthRequest, - // Default to "no assembly" so tests that don't care about diagnostics - // don't need a fake fixture. Tests that do care override this. readAssembly: opts?.readAssembly ?? (() => ({ status: 'not-found' })), + synthRunner: opts?.synthRunner, + notify: opts?.notify, logger: log, onPublishDiagnostics: (uri, diagnostics) => published.push({ uri, diagnostics }), onRefreshCodeLenses: refreshCodeLens, - // Inject a fake watcher so unit tests never start a real chokidar instance; - // capture its onChange so tests can simulate a re-synth deterministically. startAssemblyWatcher: (watchOpts) => { watcherOnChange = watchOpts.onChange; return { @@ -93,149 +103,129 @@ function bucketViolationFixtures() { return { tree, violations }; } -/** - * A readAssembly that reports a violation on the first read and the same tree - * with the violation resolved on every read after, simulating a user fixing it - * and re-synthing. - */ function readAssemblyResolvingAfterFirst(): () => AssemblyReadResult { const { tree, violations } = bucketViolationFixtures(); let call = 0; return (): AssemblyReadResult => { call += 1; - return { - status: 'success', - data: call === 1 ? { warnings: [], tree, violations } : { warnings: [], tree }, - }; + return { status: 'success', data: call === 1 ? { warnings: [], tree, violations } : { warnings: [], tree } }; }; } describe('LSP Server', () => { test('initialize advertises codeLens, definition, and save-sync capabilities', () => { const client = createTestClient(); - const result = client.handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/tmp/test-project' }, }); - expect(result).toMatchObject({ capabilities: { - textDocumentSync: { - openClose: false, - change: 0, - save: { includeText: false }, - }, + textDocumentSync: { openClose: false, change: 0, save: { includeText: false } }, codeLensProvider: { resolveProvider: false }, definitionProvider: true, }, }); }); - test('didSave triggers onSynthRequest for source files', () => { - const synthRequests: string[] = []; - const client = createTestClient({ - onSynthRequest: (dir) => synthRequests.push(dir), - }); + test('didSave triggers auto-synth for non-ignored source files when auto-synth is enabled', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); + const client = createTestClient({ synthRunner }); initializeClient(client, { applicationDir: '/tmp/test-project' }); + // Enable auto-synth via the toggle command first + await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + client.handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, }); + await new Promise((r) => setTimeout(r, 0)); - expect(synthRequests).toEqual(['/tmp/test-project']); + expect(synthRunner).toHaveBeenCalledTimes(1); }); - test('didSave does not trigger for ignored files', () => { - const synthRequests: string[] = []; - const client = createTestClient({ - onSynthRequest: (dir) => synthRequests.push(dir), - }); + test('didSave does not trigger synth when auto-synth is disabled (default)', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); + const client = createTestClient({ synthRunner }); initializeClient(client, { applicationDir: '/tmp/test-project' }); + // auto-synth is off by default client.handlers.onDidSaveTextDocument({ - textDocument: { uri: 'file:///tmp/test-project/node_modules/foo/index.ts' }, - }); - client.handlers.onDidSaveTextDocument({ - textDocument: { uri: 'file:///tmp/test-project/cdk.out/tree.json' }, + textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, }); + await new Promise((r) => setTimeout(r, 0)); - expect(synthRequests).toEqual([]); + expect(synthRunner).not.toHaveBeenCalled(); }); - test('didSave does not throw without onSynthRequest configured', () => { - const client = createTestClient(); + test('didSave does not trigger synth for ignored files', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); + const client = createTestClient({ synthRunner }); initializeClient(client, { applicationDir: '/tmp/test-project' }); + await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); - expect(() => client.handlers.onDidSaveTextDocument({ - textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, - })).not.toThrow(); - - // Server should still be responsive after didSave with no callback - expect(() => client.handlers.onShutdown()).not.toThrow(); - }); - - test('shutdown completes without error', () => { - const client = createTestClient(); - initializeClient(client); + client.handlers.onDidSaveTextDocument({ + textDocument: { uri: 'file:///tmp/test-project/node_modules/foo/index.ts' }, + }); + client.handlers.onDidSaveTextDocument({ + textDocument: { uri: 'file:///tmp/test-project/cdk.out/tree.json' }, + }); + await new Promise((r) => setTimeout(r, 0)); - expect(() => client.handlers.onShutdown()).not.toThrow(); + expect(synthRunner).not.toHaveBeenCalled(); }); - test('didSave is ignored after shutdown', () => { - const synthRequests: string[] = []; - const client = createTestClient({ - onSynthRequest: (dir) => synthRequests.push(dir), - }); + test('didSave is ignored after shutdown', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); + const client = createTestClient({ synthRunner }); initializeClient(client, { applicationDir: '/tmp/test-project' }); + await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); client.handlers.onShutdown(); - client.handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, }); + await new Promise((r) => setTimeout(r, 0)); - expect(synthRequests).toEqual([]); + expect(synthRunner).not.toHaveBeenCalled(); }); - test('onSynthRequest errors are caught gracefully', () => { - const client = createTestClient({ - onSynthRequest: () => { - throw new Error('synth failed'); - }, - }); + test('auto-synth app-failure logs to output panel without throwing', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'app-failure', message: 'compile err' }); + const client = createTestClient({ synthRunner }); initializeClient(client, { applicationDir: '/tmp/test-project' }); + await client.handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); expect(() => client.handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///tmp/test-project/lib/my-stack.ts' }, })).not.toThrow(); + await new Promise((r) => setTimeout(r, 0)); - // Server should still be responsive after the error + expect(client.log.error).toHaveBeenCalledWith(expect.stringContaining('compile err')); + }); + + test('shutdown completes without error', () => { + const client = createTestClient(); + initializeClient(client); expect(() => client.handlers.onShutdown()).not.toThrow(); }); test('publishes diagnostics on initialized when assembly has violations', () => { const { tree, violations } = bucketViolationFixtures(); const client = createTestClient({ - readAssembly: () => ({ - status: 'success', - data: { warnings: [], tree, violations }, - }), + readAssembly: () => ({ status: 'success', data: { warnings: [], tree, violations } }), }); - initializeClient(client, { applicationDir: '/p' }); - expect(client.published).toHaveLength(1); expect(client.published[0].uri).toContain('stack.ts'); expect(client.published[0].diagnostics).toHaveLength(1); }); - test('responds to codeLens with resources for the requested file', () => { + test('responds to codeLens with header + resource lenses for the requested file', () => { const stackTs = '/p/lib/stack.ts'; const stackUri = pathToFileURL(stackTs).toString(); - const client = createTestClient({ readAssembly: (): AssemblyReadResult => ({ status: 'success', @@ -256,39 +246,25 @@ describe('LSP Server', () => { }, }), }); - initializeClient(client, { applicationDir: '/p' }); - - const lenses = client.handlers.onCodeLens({ - textDocument: { uri: stackUri }, - }); - - expect(lenses).toHaveLength(1); - expect(lenses[0].range.start.line).toBe(11); // 1-based 12 -> 0-based 11 - expect(lenses[0].command?.title).toBe('Creates AWS::S3::Bucket'); + const lenses = client.handlers.onCodeLens({ textDocument: { uri: stackUri } }); + expect(lenses).toHaveLength(3); // 2 header + 1 L1 + expect(lenses[2].range.start.line).toBe(11); // 1-based 12 -> 0-based 11 + expect(lenses[2].command?.title).toBe('Creates AWS::S3::Bucket'); }); test('publishes nothing when assembly is not-found (pre-synth)', () => { - const client = createTestClient({ - readAssembly: () => ({ status: 'not-found' }), - }); - + const client = createTestClient({ readAssembly: () => ({ status: 'not-found' }) }); initializeClient(client, { applicationDir: '/p' }); - expect(client.published).toHaveLength(0); }); test('clears diagnostics for a violation resolved on a later refresh', () => { const client = createTestClient({ readAssembly: readAssemblyResolvingAfterFirst() }); - initializeClient(client, { applicationDir: '/p' }); expect(client.published).toHaveLength(1); const violationUri = client.published[0].uri; - expect(client.published[0].diagnostics).toHaveLength(1); - - // Simulate a re-synth picked up by the cdk.out watcher. client.triggerWatcher(); - expect(client.published).toHaveLength(2); expect(client.published[1]).toEqual({ uri: violationUri, diagnostics: [] }); }); @@ -300,11 +276,7 @@ describe('LSP Server', () => { data: { warnings: [], tree: [{ path: 'Stack1', id: 'Stack1', children: [] }] }, }), }); - - initializeClient(client, { applicationDir: '/p' }, { - workspace: { codeLens: { refreshSupport: true } }, - }); - + initializeClient(client, { applicationDir: '/p' }, { workspace: { codeLens: { refreshSupport: true } } }); expect(client.refreshCodeLens).toHaveBeenCalledTimes(1); }); @@ -315,25 +287,17 @@ describe('LSP Server', () => { data: { warnings: [], tree: [{ path: 'Stack1', id: 'Stack1', children: [] }] }, }), }); - initializeClient(client, { applicationDir: '/p' }); - expect(client.refreshCodeLens).not.toHaveBeenCalled(); }); test('a watcher-detected re-synth refreshes diagnostics and lenses', () => { const client = createTestClient({ readAssembly: readAssemblyResolvingAfterFirst() }); - - initializeClient(client, { applicationDir: '/p' }, { - workspace: { codeLens: { refreshSupport: true } }, - }); + initializeClient(client, { applicationDir: '/p' }, { workspace: { codeLens: { refreshSupport: true } } }); expect(client.published).toHaveLength(1); const violationUri = client.published[0].uri; expect(client.refreshCodeLens).toHaveBeenCalledTimes(1); - - // Simulate a re-synth picked up by the cdk.out watcher. client.triggerWatcher(); - expect(client.published).toHaveLength(2); expect(client.published[1]).toEqual({ uri: violationUri, diagnostics: [] }); expect(client.refreshCodeLens).toHaveBeenCalledTimes(2); @@ -342,9 +306,7 @@ describe('LSP Server', () => { test('closes the cdk.out watcher on shutdown', () => { const client = createTestClient(); initializeClient(client, { applicationDir: '/p' }); - client.handlers.onShutdown(); - expect(client.watcherClosed).toHaveBeenCalledTimes(1); }); @@ -373,13 +335,11 @@ describe('LSP Server', () => { }), }); initializeClient(client, { applicationDir: dir }); - const uri = pathToFileURL(templateFile).toString(); const position = TextDocument.create(uri, 'json', 0, text).positionAt(text.indexOf('AWS::S3::Bucket')); const target = client.handlers.onDefinition({ textDocument: { uri }, position }); - expect(target?.uri).toBe(pathToFileURL('/p/lib/stack.ts').toString()); - expect(target?.range.start).toEqual({ line: 4, character: 2 }); // 1-based (5,3) -> 0-based + expect(target?.range.start).toEqual({ line: 4, character: 2 }); } finally { fs.rmSync(dir, { recursive: true, force: true }); } @@ -388,21 +348,346 @@ describe('LSP Server', () => { test('onDefinition returns undefined for a non-template document', () => { const client = createTestClient(); initializeClient(client, { applicationDir: '/p' }); - const target = client.handlers.onDefinition({ + expect(client.handlers.onDefinition({ textDocument: { uri: pathToFileURL('/p/lib/stack.ts').toString() }, position: { line: 0, character: 0 }, - }); - expect(target).toBeUndefined(); + })).toBeUndefined(); }); test('onDefinition returns undefined (does not throw) for a non-file URI', () => { const client = createTestClient(); initializeClient(client, { applicationDir: '/p' }); - expect( - client.handlers.onDefinition({ - textDocument: { uri: 'untitled:Untitled-1' }, - position: { line: 0, character: 0 }, + expect(client.handlers.onDefinition({ + textDocument: { uri: 'untitled:Untitled-1' }, + position: { line: 0, character: 0 }, + })).toBeUndefined(); + }); +}); + +describe('LSP Server -- executeCommand', () => { + function createCommandClient(opts: Partial = {}): { + handlers: LspHandlers; + notify: NotifySink & { infoMessages: string[]; errorMessages: string[] }; + } { + const notify = makeNotifySink(); + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'not-found' }), + notify, + startAssemblyWatcher: (o) => { + return { + close: async () => { + }, + }; + }, + ...opts, + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: {} }); + handlers.onInitialized(); + return { handlers, notify }; + } + + test('onInitialize advertises executeCommandProvider with synthNow', () => { + const { handlers } = createCommandClient(); + const result = handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: {} }); + expect(result.capabilities.executeCommandProvider?.commands).toEqual(expect.arrayContaining([COMMAND_SYNTH_NOW])); + }); + + test('synthNow with no app surfaces an unavailable info message', async () => { + const synthRunner = jest.fn, [string]>().mockResolvedValue({ status: 'unavailable' }); + const { handlers, notify } = createCommandClient({ synthRunner }); + await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + expect(notify.infoMessages.some((m) => m.includes('unavailable'))).toBe(true); + }); + + test('synthNow with success is silent', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'success' }); + const { handlers, notify } = createCommandClient({ synthRunner }); + await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + expect(notify.infoMessages).toHaveLength(0); + expect(notify.errorMessages).toHaveLength(0); + }); + + test('synthNow with app-failure notifies error', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'app-failure', message: 'compile error' }); + const { handlers, notify } = createCommandClient({ synthRunner }); + await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + expect(notify.errorMessages.some((m) => m.includes('compile error'))).toBe(true); + }); + + test('synthNow in-flight latch: second concurrent call is suppressed as lock-conflict', async () => { + let resolveFirst!: () => void; + const firstSynthDone = new Promise((res) => { + resolveFirst = res; + }); + const synthRunner = jest.fn, []>() + .mockImplementationOnce(() => firstSynthDone.then(() => ({ status: 'success' } as const))) + .mockResolvedValue({ status: 'success' }); + const { handlers, notify } = createCommandClient({ synthRunner }); + + const first = handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + resolveFirst(); + await first; + + expect(notify.infoMessages.some((m) => m.includes('in progress'))).toBe(true); + expect(synthRunner).toHaveBeenCalledTimes(1); + }); + + test('didSave and executeCommand share the same in-flight latch', async () => { + let resolveFirst!: () => void; + const firstSynthDone = new Promise((res) => { + resolveFirst = res; + }); + const synthRunner = jest.fn, []>() + .mockImplementationOnce(() => firstSynthDone.then(() => ({ status: 'success' } as const))) + .mockResolvedValue({ status: 'success' }); + const notify = makeNotifySink(); + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'not-found' }), + synthRunner, + notify, + startAssemblyWatcher: () => ({ + close: async () => { + }, + }), + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); + handlers.onInitialized(); + + // Enable auto-synth so didSave triggers a synth + await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + + // Start a save-triggered synth (first, holds the latch) + handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); + + // Manual command fires while save-synth is in progress + await handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + resolveFirst(); + await new Promise((r) => setTimeout(r, 0)); + + // The manual command hit the latch → lock-conflict info message + expect(notify.infoMessages.some((m) => m.includes('in progress'))).toBe(true); + expect(synthRunner).toHaveBeenCalledTimes(1); + }); +}); + +describe('LSP Server -- auto-synth toggle', () => { + function createToggleClient(synthAvailable = true) { + const synthRunner = synthAvailable + ? jest.fn, []>().mockResolvedValue({ status: 'success' }) + : undefined; + const log = { warn: jest.fn(), error: jest.fn() }; + const refreshCodeLens = jest.fn(); + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'not-found' }), + synthRunner, + logger: log, + onRefreshCodeLenses: refreshCodeLens, + startAssemblyWatcher: () => ({ + close: async () => { + }, + }), + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); + handlers.onInitialized(); + return { handlers, synthRunner, refreshCodeLens, log }; + } + + const stackTs = '/p/lib/stack.ts'; + const stackUri = pathToFileURL(stackTs).toString(); + const treeWithResource = [{ + path: 'Stack1', + id: 'Stack1', + children: [{ + path: 'Stack1/R', + id: 'R', + logicalId: 'R1', + type: 'AWS::S3::Bucket', + sourceLocation: { file: stackTs, line: 5, column: 0 }, + children: [], + }], + }]; + + test('toggle round-trip: onCodeLens reflects new state after enableAutoSynth', async () => { + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'success', data: { warnings: [], tree: treeWithResource } }), + synthRunner: jest.fn, []>().mockResolvedValue({ status: 'success' }), + onRefreshCodeLenses: jest.fn(), + startAssemblyWatcher: () => ({ + close: async () => { + }, + }), + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); + handlers.onInitialized(); + + // Before toggle: 2 header lenses (Synth now + Enable auto-synth) + const before = handlers.onCodeLens({ textDocument: { uri: stackUri } }); + expect(before[0].command?.title).toBe('↻ Synth now'); + expect(before[1].command?.title).toBe('▶ Enable auto-synth'); + + // Toggle on + await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + + // After toggle: 1 header lens (Disable auto-synth) + const after = handlers.onCodeLens({ textDocument: { uri: stackUri } }); + expect(after[0].command?.title).toBe('⏹ Disable auto-synth'); + expect(after).toHaveLength(2); // 1 header + 1 L1 + }); + + test('toggle fires onRefreshCodeLenses', async () => { + const { handlers, refreshCodeLens } = createToggleClient(); + await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + expect(refreshCodeLens).toHaveBeenCalledTimes(1); + await handlers.onExecuteCommand({ command: 'cdk.explorer.disableAutoSynth' }); + expect(refreshCodeLens).toHaveBeenCalledTimes(2); + }); + + test('save-path unavailable result is silent (no log output)', async () => { + const synthRunner = jest.fn, [string]>().mockResolvedValue({ status: 'unavailable' }); + const log = { warn: jest.fn(), error: jest.fn() }; + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'not-found' }), + synthRunner, + logger: log, + startAssemblyWatcher: () => ({ + close: async () => { + }, + }), + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); + handlers.onInitialized(); + await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + + handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); + await new Promise((r) => setTimeout(r, 0)); + + // With no app the runner returns 'unavailable', a silent no-op on the save + // path: there is no app to synth, so nothing is reported. + expect(synthRunner).toHaveBeenCalledTimes(1); + expect(log.error).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + }); + + test('save-path lock-conflict is silent (no log output)', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'lock-conflict' }); + const log = { warn: jest.fn(), error: jest.fn() }; + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'not-found' }), + synthRunner, + logger: log, + startAssemblyWatcher: () => ({ + close: async () => { + }, }), - ).toBeUndefined(); + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); + handlers.onInitialized(); + await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + + handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); + await new Promise((r) => setTimeout(r, 0)); + + expect(log.error).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + }); + + test('save-path error result is logged', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ status: 'error', message: 'unexpected failure' }); + const log = { warn: jest.fn(), error: jest.fn() }; + const handlers = createLspHandlers({ + readAssembly: () => ({ status: 'not-found' }), + synthRunner, + logger: log, + startAssemblyWatcher: () => ({ + close: async () => { + }, + }), + }); + handlers.onInitialize({ processId: null, capabilities: {}, rootUri: null, initializationOptions: { applicationDir: '/p' } }); + handlers.onInitialized(); + await handlers.onExecuteCommand({ command: 'cdk.explorer.enableAutoSynth' }); + + handlers.onDidSaveTextDocument({ textDocument: { uri: 'file:///p/lib/stack.ts' } }); + await new Promise((r) => setTimeout(r, 0)); + + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('unexpected failure')); + }); +}); + +describe('LSP Server -- synth-failure diagnostics', () => { + test('app-failure publishes a diagnostic on the failing source file', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ + status: 'app-failure', + message: 'Subprocess exited with error 1', + details: 'lib/stack.ts:12:5 - error TS2322: nope', + }); + const client = createTestClient({ synthRunner }); + initializeClient(client, { applicationDir: '/p' }); + + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + const onFile = client.published.find((p) => p.uri === pathToFileURL('/p/lib/stack.ts').toString()); + expect(onFile?.diagnostics).toHaveLength(1); + expect(onFile?.diagnostics[0].message).toContain('TS2322'); + }); + + test('publishes a diagnostic per failing file', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ + status: 'app-failure', + message: 'm', + details: 'lib/stack.ts(1,1): error TS1000: a\nbin/app.ts(2,2): error TS1001: b', + }); + const client = createTestClient({ synthRunner }); + initializeClient(client, { applicationDir: '/p' }); + + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + expect(client.published.find((p) => p.uri === pathToFileURL('/p/lib/stack.ts').toString())?.diagnostics).toHaveLength(1); + expect(client.published.find((p) => p.uri === pathToFileURL('/p/bin/app.ts').toString())?.diagnostics).toHaveLength(1); + }); + + test('app-failure without a parseable location falls back to cdk.json', async () => { + const synthRunner = jest.fn, []>().mockResolvedValue({ + status: 'app-failure', + message: 'context needed', + }); + const client = createTestClient({ synthRunner }); + initializeClient(client, { applicationDir: '/p' }); + + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + const onCdkJson = client.published.find((p) => p.uri === pathToFileURL('/p/cdk.json').toString()); + expect(onCdkJson?.diagnostics[0].message).toBe('context needed'); + }); + + test('a successful synth clears a prior synth-failure diagnostic', async () => { + const synthRunner = jest.fn, []>() + .mockResolvedValueOnce({ status: 'app-failure', message: 'm', details: 'lib/stack.ts:1:1 - error TS1000: x' }) + .mockResolvedValueOnce({ status: 'success' }); + const client = createTestClient({ synthRunner }); + initializeClient(client, { applicationDir: '/p' }); + + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + const uri = pathToFileURL('/p/lib/stack.ts').toString(); + const entriesForFile = client.published.filter((p) => p.uri === uri); + expect(entriesForFile[entriesForFile.length - 1].diagnostics).toEqual([]); + }); + + test('an unavailable result clears a prior synth-failure diagnostic', async () => { + const synthRunner = jest.fn, [string]>() + .mockResolvedValueOnce({ status: 'app-failure', message: 'm', details: 'lib/stack.ts:1:1 - error TS1000: x' }) + .mockResolvedValueOnce({ status: 'unavailable' }); + const client = createTestClient({ synthRunner }); + initializeClient(client, { applicationDir: '/p' }); + + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + await client.handlers.onExecuteCommand({ command: COMMAND_SYNTH_NOW }); + + const uri = pathToFileURL('/p/lib/stack.ts').toString(); + const entriesForFile = client.published.filter((p) => p.uri === uri); + expect(entriesForFile[entriesForFile.length - 1].diagnostics).toEqual([]); }); }); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/synth-diagnostics.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/synth-diagnostics.test.ts new file mode 100644 index 000000000..dec4543f7 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/synth-diagnostics.test.ts @@ -0,0 +1,90 @@ +import { pathToFileURL } from 'url'; +import { DiagnosticSeverity } from 'vscode-languageserver/node'; +import type { SynthRunResult } from '../../lib/core/synth-runner'; +import { parseSynthErrors, synthFailureDiagnostics } from '../../lib/lsp/synth-diagnostics'; + +describe('parseSynthErrors', () => { + test('parses multiple ts-node (pretty) errors', () => { + const locs = parseSynthErrors( + 'lib/stack.ts:12:5 - error TS2322: Type A.\nlib/stack.ts:20:9 - error TS2554: Type B.\n', + ); + expect(locs).toEqual([ + { file: 'lib/stack.ts', line: 12, column: 5, message: 'error TS2322: Type A.' }, + { file: 'lib/stack.ts', line: 20, column: 9, message: 'error TS2554: Type B.' }, + ]); + }); + + test('parses multiple tsc-format errors (ts-node default)', () => { + const locs = parseSynthErrors( + 'lib/stack.ts(1,7): error TS2322: x\nbin/app.ts(3,1): error TS1005: y\n', + ); + expect(locs).toEqual([ + { file: 'lib/stack.ts', line: 1, column: 7, message: 'error TS2322: x' }, + { file: 'bin/app.ts', line: 3, column: 1, message: 'error TS1005: y' }, + ]); + }); + + test('returns empty when nothing matches', () => { + expect(parseSynthErrors('unrelated output')).toEqual([]); + expect(parseSynthErrors(undefined)).toEqual([]); + }); +}); + +describe('synthFailureDiagnostics', () => { + test('groups multiple errors in the same file into one entry', () => { + const result: SynthRunResult = { + status: 'app-failure', + message: 'summary', + details: 'lib/stack.ts(1,7): error TS2322: a\nlib/stack.ts(2,3): error TS1005: b', + }; + + const out = synthFailureDiagnostics(result, '/p'); + + expect(out).toHaveLength(1); + expect(out[0].uri).toBe(pathToFileURL('/p/lib/stack.ts').toString()); + expect(out[0].diagnostics).toHaveLength(2); + expect(out[0].diagnostics[0]).toEqual({ + range: { start: { line: 0, character: 6 }, end: { line: 0, character: Number.MAX_VALUE } }, + severity: DiagnosticSeverity.Error, + source: 'cdk synth', + message: 'error TS2322: a', + }); + }); + + test('produces one entry per file', () => { + const result: SynthRunResult = { + status: 'app-failure', + message: 'summary', + details: 'lib/stack.ts(1,1): error TS1: a\nbin/app.ts(2,2): error TS2: b', + }; + + const uris = synthFailureDiagnostics(result, '/p').map((o) => o.uri).sort(); + + expect(uris).toEqual([ + pathToFileURL('/p/bin/app.ts').toString(), + pathToFileURL('/p/lib/stack.ts').toString(), + ].sort()); + }); + + test('falls back to cdk.json when nothing parses', () => { + const out = synthFailureDiagnostics({ status: 'app-failure', message: 'context needed' }, '/p'); + expect(out).toHaveLength(1); + expect(out[0].uri).toBe(pathToFileURL('/p/cdk.json').toString()); + expect(out[0].diagnostics[0].message).toBe('context needed'); + }); + + test('ignores errors outside the project (falls back to cdk.json)', () => { + const out = synthFailureDiagnostics( + { status: 'app-failure', message: 'summary', details: '../evil.ts(1,1): error TS1: x' }, + '/p', + ); + expect(out).toHaveLength(1); + expect(out[0].uri).toBe(pathToFileURL('/p/cdk.json').toString()); + }); + + test('returns empty for non-app-failure outcomes', () => { + expect(synthFailureDiagnostics({ status: 'success' }, '/p')).toEqual([]); + expect(synthFailureDiagnostics({ status: 'lock-conflict' }, '/p')).toEqual([]); + expect(synthFailureDiagnostics({ status: 'error', message: 'x' }, '/p')).toEqual([]); + }); +}); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/context-aware-source.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/context-aware-source.ts index 5f427417d..c2bcef2f9 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/context-aware-source.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/context-aware-source.ts @@ -1,7 +1,7 @@ import { AsyncDisposableBox } from './disposable-box'; import * as contextproviders from '../../../context-providers'; import type { ToolkitServices } from '../../../toolkit/private'; -import { ToolkitError } from '../../../toolkit/toolkit-error'; +import { ContextLookupsDisabledError } from '../../../toolkit/toolkit-error'; import type { IoHelper } from '../../io/private'; import { IO } from '../../io/private'; import type { IContextStore } from '../context-store'; @@ -77,8 +77,7 @@ export class ContextAwareCloudAssemblySource implements ICloudAssemblySource { const missingKeys = Array.from(missingKeysSet); if (!this.canLookup) { - throw new ToolkitError( - 'ContextLookupsDisabled', + throw new ContextLookupsDisabledError( 'Context lookups have been disabled. ' + 'Make sure all necessary context is already in \'cdk.context.json\' by running \'cdk synth\' on a machine with sufficient AWS credentials and committing the result. ' + `Missing context keys: '${missingKeys.join(', ')}'`); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts b/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts index f91f54315..cfbb18b5d 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/rwlock.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'fs'; import * as path from 'path'; -import { ToolkitError } from '../toolkit/toolkit-error'; +import { LockError } from '../toolkit/toolkit-error'; /** * A single-writer/multi-reader lock on a directory @@ -34,7 +34,7 @@ export class RWLock { const readers = await this._currentReaders(); if (readers.length > 0) { - throw new ToolkitError('ConcurrentReadLock', `Other CLIs (PID=${readers}) are currently reading from ${this.directory}. Invoke the CLI in sequence, or use '--output' to synth into different directories.`); + throw new LockError('ConcurrentReadLock', `Other CLIs (PID=${readers}) are currently reading from ${this.directory}. Invoke the CLI in sequence, or use '--output' to synth into different directories.`); } await writeFileAtomic(this.writerFile, this.pidString); @@ -101,7 +101,7 @@ export class RWLock { private async assertNoOtherWriters() { const writer = await this._currentWriter(); if (writer) { - throw new ToolkitError('ConcurrentWriteLock', `Another CLI (PID=${writer}) is currently synthing to ${this.directory}. Invoke the CLI in sequence, or use '--output' to synth into different directories.`); + throw new LockError('ConcurrentWriteLock', `Another CLI (PID=${writer}) is currently synthing to ${this.directory}. Invoke the CLI in sequence, or use '--output' to synth into different directories.`); } } diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit-error.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit-error.ts index 63987a47c..8185fedc6 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit-error.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit-error.ts @@ -6,6 +6,8 @@ const DEPLOYMENT_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.DeploymentError const ASSEMBLY_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.AssemblyError'); const CONTEXT_PROVIDER_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.ContextProviderError'); const NO_RESULTS_FOUND_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.NoResultsFoundError'); +const LOCK_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.LockError'); +const CONTEXT_LOOKUPS_DISABLED_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit-lib.ContextLookupsDisabledError'); /** * Represents a general toolkit error in the AWS CDK Toolkit. @@ -39,6 +41,20 @@ export class ToolkitError extends Error { return ToolkitError.isToolkitError(x) && ASSEMBLY_ERROR_SYMBOL in x; } + /** + * Determines if a given error is an instance of LockError. + */ + public static isLockError(x: any): x is LockError { + return ToolkitError.isToolkitError(x) && LOCK_ERROR_SYMBOL in x; + } + + /** + * Determines if a given error is an instance of ContextLookupsDisabledError. + */ + public static isContextLookupsDisabledError(x: any): x is ContextLookupsDisabledError { + return ToolkitError.isToolkitError(x) && CONTEXT_LOOKUPS_DISABLED_ERROR_SYMBOL in x; + } + /** * Determines if a given error is an instance of ContextProviderError. */ @@ -95,6 +111,42 @@ export class AuthenticationError extends ToolkitError { } } +/** + * Represents a failure to acquire the read/write lock on the cloud assembly + * output directory, because another CLI is reading from or writing to it. + */ +export class LockError extends ToolkitError { + /** + * Denotes the source of the error as user. + */ + public readonly source = 'user'; + + constructor(errorCode: string, message: string) { + super(errorCode, message, 'lock'); + Object.setPrototypeOf(this, LockError.prototype); + Object.defineProperty(this, LOCK_ERROR_SYMBOL, { value: true }); + } +} + +/** + * Represents synthesis that could not complete because the app needs context + * lookups that are not cached, and lookups are disabled. The fix is to run + * `cdk synth` in a terminal once (with AWS credentials) so the values are + * written to `cdk.context.json`. + */ +export class ContextLookupsDisabledError extends ToolkitError { + /** + * Denotes the source of the error as user. + */ + public readonly source = 'user'; + + constructor(message: string) { + super('ContextLookupsDisabled', message); + Object.setPrototypeOf(this, ContextLookupsDisabledError.prototype); + Object.defineProperty(this, CONTEXT_LOOKUPS_DISABLED_ERROR_SYMBOL, { value: true }); + } +} + /** * Represents an error causes by cloud assembly synthesis * diff --git a/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit-error.test.ts b/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit-error.test.ts index 5038918fd..69d946444 100644 --- a/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit-error.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/toolkit/toolkit-error.test.ts @@ -1,4 +1,4 @@ -import { AssemblyError, AuthenticationError, ContextProviderError, NoResultsFoundError, ToolkitError } from '../../lib/toolkit/toolkit-error'; +import { AssemblyError, AuthenticationError, ContextLookupsDisabledError, ContextProviderError, LockError, NoResultsFoundError, ToolkitError } from '../../lib/toolkit/toolkit-error'; describe('toolkit error', () => { let toolkitError = new ToolkitError('TestError', 'Test toolkit error'); @@ -8,6 +8,8 @@ describe('toolkit error', () => { let assemblyError = AssemblyError.withStacks('Test authentication error', []); let assemblyCauseError = AssemblyError.withCause('Test authentication error', new Error('other error')); let noResultsError = new NoResultsFoundError('Test no results error'); + let lockError = new LockError('ConcurrentWriteLock', 'Test lock error'); + let contextLookupsDisabledError = new ContextLookupsDisabledError('Test context lookups disabled error'); test('types are correctly assigned', async () => { expect(toolkitError.type).toBe('toolkit'); @@ -16,6 +18,7 @@ describe('toolkit error', () => { expect(assemblyCauseError.type).toBe('assembly'); expect(contextProviderError.type).toBe('context-provider'); expect(noResultsError.type).toBe('context-provider'); + expect(lockError.type).toBe('lock'); }); test('isToolkitError works', () => { @@ -40,6 +43,22 @@ describe('toolkit error', () => { expect(ToolkitError.isAuthenticationError(authError)).toBe(true); }); + test('isLockError works', () => { + expect(lockError.source).toBe('user'); + + expect(ToolkitError.isLockError(lockError)).toBe(true); + expect(ToolkitError.isLockError(toolkitError)).toBe(false); + expect(ToolkitError.isLockError(authError)).toBe(false); + }); + + test('isContextLookupsDisabledError works', () => { + expect(contextLookupsDisabledError.source).toBe('user'); + + expect(ToolkitError.isContextLookupsDisabledError(contextLookupsDisabledError)).toBe(true); + expect(ToolkitError.isContextLookupsDisabledError(toolkitError)).toBe(false); + expect(ToolkitError.isContextLookupsDisabledError(lockError)).toBe(false); + }); + describe('isAssemblyError works', () => { test('AssemblyError.fromStacks', () => { expect(assemblyError.source).toBe('user');