From ae315dc38e6c8b97d206389da27e5c23a4236f0c Mon Sep 17 00:00:00 2001 From: Michael Karlesky Date: Fri, 23 Oct 2026 14:32:42 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Extract=20the=20adapte?= =?UTF-8?q?rs'=20decisions=20into=20tested,=20vscode-free=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VS Code adapters had logic that no unit test could reach. It now lives in modules that plain Node tests cover, and the adapters only wire it to the editor: - testReporting.ts: a reporter's id lookup, dropped and excluded results, gdb log links, and failure and error locations, as plain messages for a RunSink. - debugLaunch.ts: which file a debug request debugs, and the launch configuration with the executable's path spliced in. - watchPolicy.ts: what settings changes and source file events do, watcher reconciliation, and the edit-reparse Debouncer. - diagnosticsStore.ts: each run's diagnostics by id, shown by file without duplicates. - testModel.ts: controllerLabel, the Testing view's name for a folder's tests. The launch configuration splice now replaces within string values rather than in serialized JSON. A quote in the executable's path made the JSON unparseable, and a backslash would have been read as an escape. Co-Authored-By: Claude Opus 5.5 --- src/debugLaunch.ts | 46 ++++++++ src/diagnosticsStore.ts | 50 ++++++++ src/main.ts | 6 +- src/problemMatcher.ts | 37 ++---- src/testController.ts | 172 +++++++++++----------------- src/testModel.ts | 5 + src/testReporting.ts | 71 ++++++++++++ src/watchPolicy.ts | 59 ++++++++++ tests/unit/debugLaunch.test.ts | 70 +++++++++++ tests/unit/diagnosticsStore.test.ts | 55 +++++++++ tests/unit/testModel.test.ts | 12 ++ tests/unit/testReporting.test.ts | 106 +++++++++++++++++ tests/unit/watchPolicy.test.ts | 119 +++++++++++++++++++ 13 files changed, 677 insertions(+), 131 deletions(-) create mode 100644 src/debugLaunch.ts create mode 100644 src/diagnosticsStore.ts create mode 100644 src/testReporting.ts create mode 100644 src/watchPolicy.ts create mode 100644 tests/unit/debugLaunch.test.ts create mode 100644 tests/unit/diagnosticsStore.test.ts create mode 100644 tests/unit/testModel.test.ts create mode 100644 tests/unit/testReporting.test.ts create mode 100644 tests/unit/watchPolicy.test.ts diff --git a/src/debugLaunch.ts b/src/debugLaunch.ts new file mode 100644 index 0000000..d125080 --- /dev/null +++ b/src/debugLaunch.ts @@ -0,0 +1,46 @@ +// What a Testing view debug request debugs, and the launch configuration that debugs it. It has +// no `vscode` dependency. The controller starts the debug session. + +import { type RunPlanEntry } from './runPlan'; +import { type CeedlingFileNode } from './testModel'; + +// The launch.json token that stands for the built test executable, relative to Ceedling's test +// output directory. +const EXECUTABLE_TOKEN = /\$\{command:ceedlingExplorer\.debugTestExecutable\}/g; + +// Ceedling builds a whole test file into one executable, so a request covering several files +// debugs the first. `severalFiles` says the rest were left out. +export function debugTarget(plan: readonly RunPlanEntry[]): { file: CeedlingFileNode, severalFiles: boolean } | undefined { + const files = plan.flatMap((entry) => entry.kind === 'project' ? entry.files : [entry.file]); + const file = files[0]; + if (!file) { + return undefined; + } + return { file, severalFiles: new Set(files.map((f) => `${f.projectKey}::${f.id}`)).size > 1 }; +} + +// A copy of the named configuration with the executable's path spliced into every occurrence of +// the token. VS Code never resolves the token itself, so debugging doesn't depend on when it +// would. undefined when launch.json has no configuration of that name. +export function resolveLaunchConfig(configurations: readonly Config[], name: string, executableSubpath: string): Config | undefined { + const found = configurations.find((c) => c.name === name); + if (!found) { + return undefined; + } + return splice(found, executableSubpath) as Config; +} + +// A deep copy with the token replaced in every string. Replacing within strings, rather than in +// serialized JSON, keeps a backslash or quote in the path from being read as JSON escaping. +function splice(value: unknown, executableSubpath: string): unknown { + if (typeof value === 'string') { + return value.replace(EXECUTABLE_TOKEN, () => executableSubpath); + } + if (Array.isArray(value)) { + return value.map((element) => splice(element, executableSubpath)); + } + if (typeof value === 'object' && value !== null) { + return Object.fromEntries(Object.entries(value).map(([key, element]) => [key, splice(element, executableSubpath)])); + } + return value; +} diff --git a/src/diagnosticsStore.ts b/src/diagnosticsStore.ts new file mode 100644 index 0000000..3b2b1db --- /dev/null +++ b/src/diagnosticsStore.ts @@ -0,0 +1,50 @@ +// The diagnostics each run reported, kept by id: a project, or one of its files. The Problems +// panel shows their union, by file, without duplicates, since a project run and a file run can +// report the same compiler error. It has no `vscode` dependency. ProblemMatcher publishes +// `byFile()` to a DiagnosticCollection. +export class DiagnosticsStore { + private readonly entries = new Map(); + + constructor(private readonly same: (a: D, b: D) => boolean) { } + + set(id: string, diagnostics: D[]): void { + this.entries.set(id, diagnostics); + } + + // Keeps only the diagnostics under `id` that `keep` accepts. False when `id` has none. + retain(id: string, keep: (diagnostic: D) => boolean): boolean { + const current = this.entries.get(id); + if (!current) { + return false; + } + this.entries.set(id, current.filter(keep)); + return true; + } + + // Keeps only the ids `keep` accepts, e.g. dropping a deleted test file's entry. + retainIds(keep: (id: string) => boolean): void { + for (const id of [...this.entries.keys()]) { + if (!keep(id)) { + this.entries.delete(id); + } + } + } + + clear(): void { + this.entries.clear(); + } + + byFile(): Map { + const byFile = new Map(); + for (const diagnostics of this.entries.values()) { + for (const diagnostic of diagnostics) { + const shown = byFile.get(diagnostic.file) ?? []; + if (!shown.some((existing) => this.same(existing, diagnostic))) { + shown.push(diagnostic); + } + byFile.set(diagnostic.file, shown); + } + } + return byFile; + } +} diff --git a/src/main.ts b/src/main.ts index d10f971..9b9254a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import vscode from 'vscode'; -import { CeedlingTestController } from './testController'; import { Logger } from './logger'; +import { CeedlingTestController } from './testController'; +import { controllerLabel } from './testModel'; const logger = new Logger(); // Keyed by folder URI. VS Code reuses folder objects in practice but doesn't promise to. @@ -8,9 +9,8 @@ const controllers = new Map(); let isDevelopmentMode = false; let extensionPath = ''; -// The Testing view names each folder's tests when there is more than one folder. function labelFor(workspaceFolder: vscode.WorkspaceFolder): string { - return (vscode.workspace.workspaceFolders?.length ?? 0) > 1 ? `Ceedling (${workspaceFolder.name})` : 'Ceedling'; + return controllerLabel(workspaceFolder.name, vscode.workspace.workspaceFolders?.length ?? 0); } function addWorkspaceFolder(workspaceFolder: vscode.WorkspaceFolder): void { diff --git a/src/problemMatcher.ts b/src/problemMatcher.ts index 2ff3bf6..934a402 100644 --- a/src/problemMatcher.ts +++ b/src/problemMatcher.ts @@ -15,6 +15,7 @@ import { type ProblemMatchingPattern, } from './problemMatching'; import util from 'util'; +import { DiagnosticsStore } from './diagnosticsStore'; import { type Logger } from './logger'; // A Diagnostic with the absolute path of the file it belongs to. @@ -39,11 +40,11 @@ function toFileDiagnostic(problem: MatchedProblem): FileDiagnostic { } // The Problems panel side of problem matching. Pattern matching itself lives in the pure -// problemMatching.ts. This class holds the vscode state: the DiagnosticCollection, and each id's -// diagnostics. An id is whatever a run keys its diagnostics under, a project or one of its files. -// The panel shows the union of every id's diagnostics, without duplicates. +// problemMatching.ts, and each id's diagnostics in DiagnosticsStore. An id is whatever a run keys +// its diagnostics under, a project or one of its files. This class publishes the store to the +// DiagnosticCollection after every change. export class ProblemMatcher { - private readonly entries = new Map(); + private readonly store = new DiagnosticsStore((a, b) => sameDiagnostic(a.diagnostic, b.diagnostic)); private readonly diagnosticCollection: DiagnosticCollection; constructor(private readonly logger: Logger) { @@ -76,31 +77,25 @@ export class ProblemMatcher { } set(id: string, diagnostics: FileDiagnostic[]): void { - this.entries.set(id, diagnostics); + this.store.set(id, diagnostics); this.publish(); } // Keeps only the diagnostics under `id` that `keep` accepts. retain(id: string, keep: (diagnostic: FileDiagnostic) => boolean): void { - const current = this.entries.get(id); - if (current) { - this.entries.set(id, current.filter(keep)); + if (this.store.retain(id, keep)) { this.publish(); } } // Keeps only the ids `keep` accepts, e.g. dropping a deleted test file's entry. retainIds(keep: (id: string) => boolean): void { - for (const id of [...this.entries.keys()]) { - if (!keep(id)) { - this.entries.delete(id); - } - } + this.store.retainIds(keep); this.publish(); } clear(): void { - this.entries.clear(); + this.store.clear(); this.diagnosticCollection.clear(); } @@ -110,19 +105,9 @@ export class ProblemMatcher { } private publish(): void { - const byFile = new Map(); - for (const fileDiagnostics of this.entries.values()) { - for (const { file, diagnostic } of fileDiagnostics) { - const diagnostics = byFile.get(file) ?? []; - if (!diagnostics.some((existing) => sameDiagnostic(existing, diagnostic))) { - diagnostics.push(diagnostic); - } - byFile.set(file, diagnostics); - } - } this.diagnosticCollection.clear(); - for (const [file, diagnostics] of byFile) { - this.diagnosticCollection.set(Uri.file(file), diagnostics); + for (const [file, diagnostics] of this.store.byFile()) { + this.diagnosticCollection.set(Uri.file(file), diagnostics.map((d) => d.diagnostic)); } } } diff --git a/src/testController.ts b/src/testController.ts index 2807e65..8f9bb76 100644 --- a/src/testController.ts +++ b/src/testController.ts @@ -3,18 +3,18 @@ import path from 'path'; import util from 'util'; import vscode from 'vscode'; import { CeedlingEngine, CeedlingLoadError } from './ceedlingEngine'; -import { extractGdbLogReference } from './ceedlingOutputParsing'; +import { debugTarget, resolveLaunchConfig } from './debugLaunch'; import { type Logger } from './logger'; import { ProblemMatcher } from './problemMatcher'; import { buildRunPlan, type PlanTarget, type RunPlanEntry } from './runPlan'; import { SingleFlight } from './singleFlight'; import { type CeedlingFileNode, type CeedlingProjectNode, type CeedlingTestFunction, type RunReporter } from './testModel'; +import { createRunReporter, type ReportMessage } from './testReporting'; +import { Debouncer, settingsChangeEffects, sourceEventEffect, watcherChanges } from './watchPolicy'; // Names the launch configuration of one debug request, so its debug session can be told apart // from any other session VS Code starts meanwhile. const DEBUG_REQUEST_KEY = '__ceedlingDebugRequest'; -// Settings whose change changes what discovery finds. The rest apply on the next run. -const DISCOVERY_SETTINGS = ['projects', 'mixins', 'shellPath', 'ceedlingCommand', 'prettyTestLabel', 'prettyTestFileLabel']; // How long typing pauses before an unsaved test file is reparsed. const EDIT_REPARSE_DELAY_MS = 400; @@ -33,7 +33,7 @@ export class CeedlingTestController { // project directory's C sources. private readonly configurationWatchers = new Map(); private readonly sourceWatchers = new Map(); - private readonly pendingReparses = new Map(); + private readonly editReparses = new Debouncer(EDIT_REPARSE_DELAY_MS); private readonly targets = new WeakMap(); // Item ids are unique only within a project, so lookups are scoped by project key. @@ -84,7 +84,7 @@ export class CeedlingTestController { dispose(): void { this.logger.debug(`CeedlingTestController disposed for ${this.workspaceFolder.uri.toString()}`); - this.pendingReparses.forEach((timer) => clearTimeout(timer)); + this.editReparses.dispose(); [...this.configurationWatchers.values(), ...this.sourceWatchers.values(), ...this.disposables].forEach((d) => { d.dispose(); }); this.disposeController(); this.engine.dispose(); @@ -119,19 +119,20 @@ export class CeedlingTestController { } private onConfigurationChanged(event: vscode.ConfigurationChangeEvent): void { - const affects = (setting: string) => event.affectsConfiguration(`ceedlingExplorer.${setting}`, this.workspaceFolder.uri); - if (affects('problemMatching') && this.engine.getConfiguration().get('problemMatching.mode', 'disabled') === 'disabled') { + const { clearDiagnostics, rediscoverFor } = settingsChangeEffects( + (setting) => event.affectsConfiguration(`ceedlingExplorer.${setting}`, this.workspaceFolder.uri), + () => this.engine.getConfiguration().get('problemMatching.mode', 'disabled'), + ); + if (clearDiagnostics) { this.logger.debug(`onConfigurationChanged: problem matching disabled, clearing diagnostics`); this.engine.problemMatcher.clear(); } - const changed = DISCOVERY_SETTINGS.filter(affects); - if (changed.length > 0) { - this.logger.debug(`onConfigurationChanged: ${changed.join(', ')} changed, refreshing`); + if (rediscoverFor.length > 0) { + this.logger.debug(`onConfigurationChanged: ${rediscoverFor.join(', ')} changed, refreshing`); void this.refresh(); } } - private async runTask(name: string, task: (token: vscode.CancellationToken) => Promise): Promise { this.logger.trace(`${name.toLowerCase()}()`); const succeeded = await vscode.window.withProgress( @@ -168,9 +169,8 @@ export class CeedlingTestController { await this.updateWatchers(); } - // Configuration files rediscover everything. A C source change in a project directory - // reparses that test file alone. A test file appearing or disappearing rediscovers, since only - // `ceedling files:test` knows the full list. + // Configuration files rediscover everything. C sources in a project directory follow + // sourceEventEffect. private async updateWatchers(): Promise { const configurationFiles = new Set(await this.engine.configurationFiles()); const projectDirectories = new Set(this.projects.map((p) => this.engine.getProjectAbsPath(p.projectKey))); @@ -185,15 +185,17 @@ export class CeedlingTestController { }); this.syncWatchers(this.sourceWatchers, projectDirectories, (directory) => new vscode.RelativePattern(vscode.Uri.file(directory), '**/*.c'), - (watcher) => [ - watcher.onDidChange((uri) => void this.reparse(uri.fsPath)), - watcher.onDidCreate((uri) => { - if (this.engine.mayBeTestFile(uri.fsPath)) void this.refresh(); - }), - watcher.onDidDelete((uri) => { - if (this.engine.testFileAt(uri.fsPath)) void this.refresh(); - }), - ]); + (watcher) => { + const onSource = (event: 'changed' | 'created' | 'deleted') => (uri: vscode.Uri) => { + const effect = sourceEventEffect(event, { + isTestFile: () => this.engine.testFileAt(uri.fsPath) !== undefined, + mayBeTestFile: () => this.engine.mayBeTestFile(uri.fsPath), + }); + if (effect === 'reparse') void this.reparse(uri.fsPath); + else if (effect === 'rediscover') void this.refresh(); + }; + return [watcher.onDidChange(onSource('changed')), watcher.onDidCreate(onSource('created')), watcher.onDidDelete(onSource('deleted'))]; + }); } private syncWatchers( @@ -201,17 +203,14 @@ export class CeedlingTestController { pattern: (key: string) => vscode.RelativePattern, listen: (watcher: vscode.FileSystemWatcher, key: string) => vscode.Disposable[], ): void { - for (const [key, watcher] of watchers) { - if (!wanted.has(key)) { - watcher.dispose(); - watchers.delete(key); - } + const { stale, added } = watcherChanges(watchers.keys(), wanted); + for (const key of stale) { + watchers.get(key)?.dispose(); + watchers.delete(key); } - for (const key of wanted) { - if (!watchers.has(key)) { - const watcher = vscode.workspace.createFileSystemWatcher(pattern(key)); - watchers.set(key, vscode.Disposable.from(watcher, ...listen(watcher, key))); - } + for (const key of added) { + const watcher = vscode.workspace.createFileSystemWatcher(pattern(key)); + watchers.set(key, vscode.Disposable.from(watcher, ...listen(watcher, key))); } } @@ -221,11 +220,7 @@ export class CeedlingTestController { if (document.uri.scheme !== 'file' || !this.engine.testFileAt(file)) { return; } - clearTimeout(this.pendingReparses.get(file)); - this.pendingReparses.set(file, setTimeout(() => { - this.pendingReparses.delete(file); - void this.reparse(file, document.getText()); - }, EDIT_REPARSE_DELAY_MS)); + this.editReparses.schedule(file, () => void this.reparse(file, document.getText())); } // Replaces one test file's functions and cases in the tree, without running Ceedling. Watchers @@ -355,54 +350,21 @@ export class CeedlingTestController { private makeReporter(run: vscode.TestRun, projectKey: string, excluded: Set): RunReporter { const items = this.items.get(projectKey); - const lookup = (callback: string, id: string): vscode.TestItem | undefined => { - const item = items?.get(id); - if (!item) { - this.logger.warn(`makeReporter.${callback}: no TestItem for '${id}' in project '${projectKey}', dropping this result`); - } - return item && !excluded.has(item) ? item : undefined; - }; - return { - started: (id) => { - const item = lookup('started', id); - if (item) run.started(item); - }, - passed: (id) => { - const item = lookup('passed', id); - if (item) run.passed(item); - }, - failed: (id, message, line) => { - const item = lookup('failed', id); - if (!item) return; - // Ceedling 1.1.0+'s :use_backtrace: :gdb ends a crash message with a gdb log - // path, relative to the project directory. It becomes a clickable link. - const { text, logPath } = extractGdbLogReference(message); - const testMessage = logPath !== undefined - ? new vscode.TestMessage(new vscode.MarkdownString( - `${text}\n\n[${logPath}](${vscode.Uri.file(path.resolve(this.engine.getProjectAbsPath(projectKey), logPath)).toString()})` - )) - : new vscode.TestMessage(message); - if (line !== undefined && item.uri) { - testMessage.location = new vscode.Location(item.uri, new vscode.Position(line, 0)); - } - run.failed(item, testMessage); + return createRunReporter({ + sink: { + started: (item) => { run.started(item); }, + passed: (item) => { run.passed(item); }, + failed: (item, message) => { run.failed(item, testMessage(message, item)); }, + skipped: (item) => { run.skipped(item); }, + errored: (item, message) => { run.errored(item, testMessage(message, item)); }, + appendOutput: (text) => { run.appendOutput(text); }, }, - skipped: (id) => { - const item = lookup('skipped', id); - if (item) run.skipped(item); - }, - errored: (id, message, location) => { - const item = lookup('errored', id); - if (!item) return; - const testMessage = new vscode.TestMessage(message); - if (location) { - // The diagnostic's own file, which may be a header rather than the test file. - testMessage.location = new vscode.Location(vscode.Uri.file(location.file), new vscode.Position(location.line, 0)); - } - run.errored(item, testMessage); - }, - appendOutput: (text) => run.appendOutput(text), - }; + projectKey, + find: (id) => items?.get(id), + excluded: (item) => excluded.has(item), + projectAbsPath: () => this.engine.getProjectAbsPath(projectKey), + warn: (message) => { this.logger.warn(message); }, + }); } // Cancellation reaches Ceedling through `token`, which stops only this run's processes. @@ -443,14 +405,13 @@ export class CeedlingTestController { const run = this.ensureController().createTestRun(request); const listeners: vscode.Disposable[] = []; try { - const plan = this.planFor(request); - const files = plan.flatMap((entry) => entry.kind === 'project' ? entry.files : [entry.file]); - const file = files[0]; - if (!file) { + const target = debugTarget(this.planFor(request)); + if (!target) { this.logger.debug(`handleDebug: nothing to debug`); return; } - if (new Set(files.map((f) => `${f.projectKey}::${f.id}`)).size > 1) { + const { file } = target; + if (target.severalFiles) { vscode.window.showInformationMessage(`Debugging ${file.id} only. Ceedling debugs one test file at a time.`); } @@ -501,21 +462,28 @@ export class CeedlingTestController { } } - // Splices the built test executable's relative path into every occurrence of - // ${command:ceedlingExplorer.debugTestExecutable} in the named launch.json configuration. - // VS Code never resolves the token itself, so debugging doesn't depend on when it would. + // The named launch.json configuration, debugging the built test executable. private resolveLaunchConfig(debugLaunchConfig: string, executableSubpath: string): vscode.DebugConfiguration | undefined { const configurations = vscode.workspace .getConfiguration('launch', this.workspaceFolder.uri) .get('configurations', []); - const found = configurations.find((c) => c.name === debugLaunchConfig); - if (!found) { - this.logger.debug(`resolveLaunchConfig: '${debugLaunchConfig}' not found among ${configurations.length} launch.json configuration(s)`); - return undefined; - } - this.logger.debug(`resolveLaunchConfig: resolved '${debugLaunchConfig}' with executableSubpath='${executableSubpath}'`); - return JSON.parse( - JSON.stringify(found).replace(/\$\{command:ceedlingExplorer\.debugTestExecutable\}/g, () => executableSubpath) - ) as vscode.DebugConfiguration; + const config = resolveLaunchConfig(configurations, debugLaunchConfig, executableSubpath); + this.logger.debug(config + ? `resolveLaunchConfig: resolved '${debugLaunchConfig}' with executableSubpath='${executableSubpath}'` + : `resolveLaunchConfig: '${debugLaunchConfig}' not found among ${configurations.length} launch.json configuration(s)`); + return config; + } +} + +// A reported message as the Testing view shows it: a gdb log as a link, a location as a place in +// the editor, by default in the test's own file. +function testMessage(message: ReportMessage, item: vscode.TestItem): vscode.TestMessage { + const shown = message.log + ? new vscode.TestMessage(new vscode.MarkdownString(`${message.text}\n\n[${message.log.label}](${vscode.Uri.file(message.log.absPath).toString()})`)) + : new vscode.TestMessage(message.text); + const uri = message.location?.file !== undefined ? vscode.Uri.file(message.location.file) : item.uri; + if (message.location && uri) { + shown.location = new vscode.Location(uri, new vscode.Position(message.location.line, 0)); } + return shown; } diff --git a/src/testModel.ts b/src/testModel.ts index b76c410..a0441c0 100644 --- a/src/testModel.ts +++ b/src/testModel.ts @@ -46,3 +46,8 @@ export function leafIds(functions: readonly CeedlingTestFunction[]): string[] { export function allIds(file: CeedlingFileNode): string[] { return [file.id, ...file.functions.flatMap((fn) => [fn.id, ...fn.cases.map((c) => c.id)])]; } + +// The Testing view names each folder's tests when there is more than one folder. +export function controllerLabel(folderName: string, folderCount: number): string { + return folderCount > 1 ? `Ceedling (${folderName})` : 'Ceedling'; +} diff --git a/src/testReporting.ts b/src/testReporting.ts new file mode 100644 index 0000000..a2fb4fd --- /dev/null +++ b/src/testReporting.ts @@ -0,0 +1,71 @@ +// Turns the engine's results, reported by id, into calls on a Testing view run. Results for ids +// with no test item are dropped with a warning, and excluded items get nothing. Messages are +// plain data here, so this has no `vscode` dependency. The controller's RunSink builds the +// TestMessages, links, and locations from them. + +import path from 'path'; +import { extractGdbLogReference } from './ceedlingOutputParsing'; +import { type RunReporter } from './testModel'; + +export type ReportMessage = { + text: string, + // A crash's gdb log, shown as a link to the file. + log?: { label: string, absPath: string }, + // A 0-based line in `file`, or in the test's own file when `file` is absent. + location?: { line: number, file?: string }, +}; + +// A test run, as the reporter calls it, with each id already resolved to its item. +export interface RunSink { + started(item: Item): void; + passed(item: Item): void; + failed(item: Item, message: ReportMessage): void; + skipped(item: Item): void; + errored(item: Item, message: ReportMessage): void; + appendOutput(text: string): void; +} + +export type RunReporterOptions = { + sink: RunSink, + projectKey: string, + find: (id: string) => Item | undefined, + // An excluded item can still be run, e.g. one case of a function, but reports nothing. + excluded: (item: Item) => boolean, + // Looked up only for a gdb log link, whose path is relative to the project directory. + projectAbsPath: () => string, + warn: (message: string) => void, +}; + +export function createRunReporter(options: RunReporterOptions): RunReporter { + const { sink, projectKey, find, excluded, projectAbsPath, warn } = options; + // Each reporting call runs `report` with the id's item, unless there is none to report to. + const withItem = (callback: string, id: string, report: (item: Item) => void) => { + const item = find(id); + if (item === undefined) { + warn(`makeReporter.${callback}: no TestItem for '${id}' in project '${projectKey}', dropping this result`); + } else if (!excluded(item)) { + report(item); + } + }; + return { + started: (id) => { withItem('started', id, (item) => { sink.started(item); }); }, + passed: (id) => { withItem('passed', id, (item) => { sink.passed(item); }); }, + failed: (id, message, line) => { + withItem('failed', id, (item) => { + // Ceedling 1.1.0+'s :use_backtrace: :gdb ends a crash message with a gdb log path, + // relative to the project directory. + const { text, logPath } = extractGdbLogReference(message); + sink.failed(item, { + text, + ...(logPath !== undefined && { log: { label: logPath, absPath: path.resolve(projectAbsPath(), logPath) } }), + ...(line !== undefined && { location: { line } }), + }); + }); + }, + skipped: (id) => { withItem('skipped', id, (item) => { sink.skipped(item); }); }, + errored: (id, message, location) => { + withItem('errored', id, (item) => { sink.errored(item, { text: message, ...(location && { location }) }); }); + }, + appendOutput: (text) => { sink.appendOutput(text); }, + }; +} diff --git a/src/watchPolicy.ts b/src/watchPolicy.ts new file mode 100644 index 0000000..155afaa --- /dev/null +++ b/src/watchPolicy.ts @@ -0,0 +1,59 @@ +// What a settings change, a watched file's event, or an edit makes the controller do. It has no +// `vscode` dependency. The controller owns the watchers and listeners themselves. + +// Settings whose change changes what discovery finds. The rest apply on the next run. +const DISCOVERY_SETTINGS = ['projects', 'mixins', 'shellPath', 'ceedlingCommand', 'prettyTestLabel', 'prettyTestFileLabel']; + +// `affects` says whether a ceedlingExplorer setting changed. Turning problem matching off clears +// its diagnostics at once rather than at the next run. `problemMatchingMode` is read only then. +export function settingsChangeEffects(affects: (setting: string) => boolean, problemMatchingMode: () => string): { clearDiagnostics: boolean, rediscoverFor: string[] } { + return { + clearDiagnostics: affects('problemMatching') && problemMatchingMode() === 'disabled', + rediscoverFor: DISCOVERY_SETTINGS.filter(affects), + }; +} + +export type SourceEvent = 'changed' | 'created' | 'deleted'; + +// A C source event in a project directory. A change reparses that file, if it is a discovered +// test file. A test file appearing or disappearing rediscovers, since only `ceedling files:test` +// knows the full list. +export function sourceEventEffect(event: SourceEvent, file: { isTestFile: () => boolean, mayBeTestFile: () => boolean }): 'reparse' | 'rediscover' | 'none' { + switch (event) { + case 'changed': return 'reparse'; + case 'created': return file.mayBeTestFile() ? 'rediscover' : 'none'; + case 'deleted': return file.isTestFile() ? 'rediscover' : 'none'; + } +} + +// The watchers to dispose and to create, keyed by what each watches, so that exactly `wanted` is watched. +export function watcherChanges(current: Iterable, wanted: ReadonlySet): { stale: string[], added: string[] } { + const existing = new Set(current); + return { stale: [...existing].filter((key) => !wanted.has(key)), added: [...wanted].filter((key) => !existing.has(key)) }; +} + +export type Timers = { + setTimeout(action: () => void, ms: number): Handle, + clearTimeout(handle: Handle | undefined): void, +}; + +// Runs the latest action scheduled for a key once `delayMs` pass without another, e.g. reparsing +// an edited file once typing pauses. Tests substitute `timers`. +export class Debouncer { + private readonly pending = new Map(); + + constructor(private readonly delayMs: number, private readonly timers: Timers = globalThis as unknown as Timers) { } + + schedule(key: string, action: () => void): void { + this.timers.clearTimeout(this.pending.get(key)); + this.pending.set(key, this.timers.setTimeout(() => { + this.pending.delete(key); + action(); + }, this.delayMs)); + } + + dispose(): void { + this.pending.forEach((handle) => { this.timers.clearTimeout(handle); }); + this.pending.clear(); + } +} diff --git a/tests/unit/debugLaunch.test.ts b/tests/unit/debugLaunch.test.ts new file mode 100644 index 0000000..a221a1d --- /dev/null +++ b/tests/unit/debugLaunch.test.ts @@ -0,0 +1,70 @@ +import * as assert from 'assert'; +import { debugTarget, resolveLaunchConfig } from '../../src/debugLaunch'; +import { type RunPlanEntry } from '../../src/runPlan'; +import { type CeedlingFileNode } from '../../src/testModel'; + +function file(projectKey: string, id: string): CeedlingFileNode { + return { id, label: id, absPath: `/ws/${id}`, projectKey, functions: [{ id: `${id}::test_a`, label: 'a', line: 0, cases: [] }, { id: `${id}::test_b`, label: 'b', line: 1, cases: [] }] }; +} + +const fileA = file('p', 'test/test_a.c'); +const fileB = file('p', 'test/test_b.c'); +const otherA = file('q', 'test/test_a.c'); + +suite('debugTarget', () => { + test('nothing to debug in an empty plan', () => { + assert.strictEqual(debugTarget([]), undefined); + }); + + test('a file entry debugs that file alone', () => { + assert.deepStrictEqual(debugTarget([{ kind: 'file', projectKey: 'p', file: fileA }]), { file: fileA, severalFiles: false }); + }); + + test('several functions of one file are still one file', () => { + const plan: RunPlanEntry[] = [ + { kind: 'function', projectKey: 'p', file: fileA, fn: fileA.functions[0] }, + { kind: 'function', projectKey: 'p', file: fileA, fn: fileA.functions[1] }, + ]; + assert.deepStrictEqual(debugTarget(plan), { file: fileA, severalFiles: false }); + }); + + // Ceedling builds one executable per test file, so only one file can be debugged at a time. + test('a plan naming several files debugs the first and says there were several', () => { + assert.deepStrictEqual(debugTarget([{ kind: 'project', projectKey: 'p', files: [fileA, fileB] }]), { file: fileA, severalFiles: true }); + }); + + test('files with the same path in different projects are different files', () => { + const plan: RunPlanEntry[] = [{ kind: 'file', projectKey: 'p', file: fileA }, { kind: 'file', projectKey: 'q', file: otherA }]; + assert.deepStrictEqual(debugTarget(plan), { file: fileA, severalFiles: true }); + }); +}); + +suite('resolveLaunchConfig', () => { + const TOKEN = '${command:ceedlingExplorer.debugTestExecutable}'; + const gdb = { name: 'gdb', type: 'cppdbg', program: `\${workspaceFolder}/build/test/out/${TOKEN}`, args: [TOKEN, '--flag'], env: { EXE: TOKEN }, stopAtEntry: false, port: 3333, miDebuggerPath: null }; + const configurations: Array & { name: string }> = [{ name: 'other', type: 'cppdbg', program: 'x' }, gdb]; + + test('a name not in launch.json resolves to nothing', () => { + assert.strictEqual(resolveLaunchConfig(configurations, 'missing', 'x/x.out'), undefined); + }); + + test("every occurrence of the token becomes the executable's path, everything else untouched", () => { + assert.deepStrictEqual(resolveLaunchConfig(configurations, 'gdb', 'test_calc/test_calc.out'), { + name: 'gdb', type: 'cppdbg', + program: '${workspaceFolder}/build/test/out/test_calc/test_calc.out', + args: ['test_calc/test_calc.out', '--flag'], + env: { EXE: 'test_calc/test_calc.out' }, + stopAtEntry: false, port: 3333, miDebuggerPath: null, + }); + }); + + test('the configuration from launch.json is left unchanged', () => { + resolveLaunchConfig(configurations, 'gdb', 'test_calc/test_calc.out'); + assert.strictEqual(gdb.args[0], TOKEN); + }); + + test('a path with backslashes, quotes, or replacement patterns arrives intact', () => { + const subpath = 'unit\\test "odd" $& $1/test.exe'; + assert.deepStrictEqual(resolveLaunchConfig(configurations, 'gdb', subpath)?.args, [subpath, '--flag']); + }); +}); diff --git a/tests/unit/diagnosticsStore.test.ts b/tests/unit/diagnosticsStore.test.ts new file mode 100644 index 0000000..d235f61 --- /dev/null +++ b/tests/unit/diagnosticsStore.test.ts @@ -0,0 +1,55 @@ +import * as assert from 'assert'; +import { DiagnosticsStore } from '../../src/diagnosticsStore'; + +type D = { file: string, message: string }; +const d = (file: string, message: string): D => ({ file, message }); +const store = () => new DiagnosticsStore((a, b) => a.message === b.message); +const view = (byFile: Map) => Object.fromEntries([...byFile].map(([file, ds]) => [file, ds.map((x) => x.message)])); + +suite('DiagnosticsStore', () => { + test('shows every id\'s diagnostics, grouped by file', () => { + const s = store(); + s.set('p', [d('/a.c', 'one'), d('/b.h', 'two')]); + s.set('p::test/t.c', [d('/a.c', 'three')]); + assert.deepStrictEqual(view(s.byFile()), { '/a.c': ['one', 'three'], '/b.h': ['two'] }); + }); + + // A project run and a file run can both report the same compiler error. + test('the same diagnostic under two ids shows once', () => { + const s = store(); + s.set('p', [d('/a.c', 'same')]); + s.set('p::test/t.c', [d('/a.c', 'same'), d('/a.c', 'other')]); + assert.deepStrictEqual(view(s.byFile()), { '/a.c': ['same', 'other'] }); + }); + + test('setting an id replaces only its own diagnostics', () => { + const s = store(); + s.set('p', [d('/a.c', 'project')]); + s.set('q', [d('/a.c', 'old')]); + s.set('q', [d('/a.c', 'new')]); + assert.deepStrictEqual(view(s.byFile()), { '/a.c': ['project', 'new'] }); + }); + + test('retain filters one id, and reports whether there was anything to filter', () => { + const s = store(); + s.set('p', [d('/a.c', 'keep'), d('/t.c', 'drop')]); + assert.strictEqual(s.retain('p', (x) => x.file !== '/t.c'), true); + assert.strictEqual(s.retain('missing', () => false), false); + assert.deepStrictEqual(view(s.byFile()), { '/a.c': ['keep'] }); + }); + + test('retainIds drops whole ids, e.g. a deleted test file\'s', () => { + const s = store(); + s.set('p', [d('/a.c', 'p')]); + s.set('p::gone.c', [d('/gone.c', 'gone')]); + s.retainIds((id) => id !== 'p::gone.c'); + assert.deepStrictEqual(view(s.byFile()), { '/a.c': ['p'] }); + }); + + test('clear drops everything', () => { + const s = store(); + s.set('p', [d('/a.c', 'p')]); + s.clear(); + assert.deepStrictEqual(view(s.byFile()), {}); + }); +}); diff --git a/tests/unit/testModel.test.ts b/tests/unit/testModel.test.ts new file mode 100644 index 0000000..9bf5082 --- /dev/null +++ b/tests/unit/testModel.test.ts @@ -0,0 +1,12 @@ +import * as assert from 'assert'; +import { controllerLabel } from '../../src/testModel'; + +suite('controllerLabel', () => { + test('a single folder\'s tests are simply Ceedling', () => { + assert.strictEqual(controllerLabel('firmware', 1), 'Ceedling'); + }); + + test('with several folders, each folder\'s tests are named by the folder', () => { + assert.strictEqual(controllerLabel('firmware', 2), 'Ceedling (firmware)'); + }); +}); diff --git a/tests/unit/testReporting.test.ts b/tests/unit/testReporting.test.ts new file mode 100644 index 0000000..b522db5 --- /dev/null +++ b/tests/unit/testReporting.test.ts @@ -0,0 +1,106 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import { createRunReporter, type ReportMessage, type RunSink } from '../../src/testReporting'; + +type Item = { id: string }; +type SinkCall = [string, ...unknown[]]; + +function recordingSink(): RunSink & { calls: SinkCall[] } { + const calls: SinkCall[] = []; + return { + calls, + started: (item) => calls.push(['started', item.id]), + passed: (item) => calls.push(['passed', item.id]), + failed: (item, message) => calls.push(['failed', item.id, message]), + skipped: (item) => calls.push(['skipped', item.id]), + errored: (item, message) => calls.push(['errored', item.id, message]), + appendOutput: (text) => calls.push(['appendOutput', text]), + }; +} + +function reporterFor(ids: string[], options: { excluded?: string[], projectAbsPath?: () => string } = {}) { + const sink = recordingSink(); + const warnings: string[] = []; + const items = new Map(ids.map((id) => [id, { id }])); + const reporter = createRunReporter({ + sink, + projectKey: 'fw', + find: (id) => items.get(id), + excluded: (item) => (options.excluded ?? []).includes(item.id), + projectAbsPath: options.projectAbsPath ?? (() => '/ws/fw'), + warn: (message) => warnings.push(message), + }); + return { reporter, sink, warnings }; +} + +suite('createRunReporter', () => { + test('each result reaches the test run against its item', () => { + const { reporter, sink } = reporterFor(['a', 'b', 'c']); + reporter.started('a'); + reporter.passed('a'); + reporter.skipped('b'); + reporter.appendOutput('text\r\n'); + assert.deepStrictEqual(sink.calls, [['started', 'a'], ['passed', 'a'], ['skipped', 'b'], ['appendOutput', 'text\r\n']]); + }); + + test('a result for an id with no item is dropped, with a warning naming the id and project', () => { + const { reporter, sink, warnings } = reporterFor(['a']); + reporter.passed('gone'); + reporter.failed('gone', 'message', 3); + assert.deepStrictEqual(sink.calls, []); + assert.strictEqual(warnings.length, 2); + assert.match(warnings[0], /passed.*'gone'.*'fw'/); + assert.match(warnings[1], /failed.*'gone'.*'fw'/); + }); + + // A run can't always leave an excluded test out of what Ceedling runs, e.g. one case of a function. + test('an excluded item gets no result, and no warning', () => { + const { reporter, sink, warnings } = reporterFor(['a', 'b'], { excluded: ['b'] }); + reporter.started('b'); + reporter.failed('b', 'message'); + reporter.errored('b', 'message'); + reporter.passed('a'); + assert.deepStrictEqual(sink.calls, [['passed', 'a']]); + assert.deepStrictEqual(warnings, []); + }); + + test('a failure points at its line in the test file, when it has one', () => { + const { reporter, sink } = reporterFor(['a', 'b']); + reporter.failed('a', 'Expected 1 Was 2', 4); + reporter.failed('b', 'no location'); + assert.deepStrictEqual(sink.calls, [ + ['failed', 'a', { text: 'Expected 1 Was 2', location: { line: 4 } } satisfies ReportMessage], + ['failed', 'b', { text: 'no location' } satisfies ReportMessage], + ]); + }); + + // Ceedling 1.1.0+'s :use_backtrace: :gdb ends a crash message with the gdb log's path. + test("a crash's gdb log becomes a link to it, resolved against the project directory", () => { + const { reporter, sink } = reporterFor(['a']); + reporter.failed('a', 'Test crashed\n(build/test/test_calc.gdb.log)', 7); + assert.deepStrictEqual(sink.calls, [['failed', 'a', { + text: 'Test crashed', + log: { label: 'build/test/test_calc.gdb.log', absPath: path.resolve('/ws/fw', 'build/test/test_calc.gdb.log') }, + location: { line: 7 }, + } satisfies ReportMessage]]); + }); + + test('the project directory is looked up only for a gdb log link', () => { + let lookups = 0; + const { reporter } = reporterFor(['a'], { projectAbsPath: () => { lookups++; return '/ws/fw'; } }); + reporter.failed('a', 'plain failure', 1); + assert.strictEqual(lookups, 0); + reporter.failed('a', 'crash\n(x.gdb.log)', 1); + assert.strictEqual(lookups, 1); + }); + + test("an error points at its diagnostic's own file, which may be a header", () => { + const { reporter, sink } = reporterFor(['a', 'b']); + reporter.errored('a', 'compile failed', { file: '/ws/fw/src/calc.h', line: 2 }); + reporter.errored('b', 'no report'); + assert.deepStrictEqual(sink.calls, [ + ['errored', 'a', { text: 'compile failed', location: { file: '/ws/fw/src/calc.h', line: 2 } } satisfies ReportMessage], + ['errored', 'b', { text: 'no report' } satisfies ReportMessage], + ]); + }); +}); diff --git a/tests/unit/watchPolicy.test.ts b/tests/unit/watchPolicy.test.ts new file mode 100644 index 0000000..f8f5864 --- /dev/null +++ b/tests/unit/watchPolicy.test.ts @@ -0,0 +1,119 @@ +import * as assert from 'assert'; +import { Debouncer, settingsChangeEffects, sourceEventEffect, watcherChanges } from '../../src/watchPolicy'; + +suite('settingsChangeEffects', () => { + const affecting = (...settings: string[]) => (setting: string) => settings.includes(setting); + + test('a setting that changes what discovery finds rediscovers, naming it', () => { + for (const setting of ['projects', 'mixins', 'shellPath', 'ceedlingCommand', 'prettyTestLabel', 'prettyTestFileLabel']) { + assert.deepStrictEqual(settingsChangeEffects(affecting(setting), () => 'gcc'), { clearDiagnostics: false, rediscoverFor: [setting] }); + } + }); + + // These apply on the next run, from the settings read then. + test('a setting a run reads for itself changes nothing now', () => { + assert.deepStrictEqual(settingsChangeEffects(affecting('testCommandArgs', 'ansiEscapeSequencesRemoved'), () => 'gcc'), + { clearDiagnostics: false, rediscoverFor: [] }); + }); + + test('turning problem matching off clears its diagnostics, without rediscovering', () => { + assert.deepStrictEqual(settingsChangeEffects(affecting('problemMatching'), () => 'disabled'), { clearDiagnostics: true, rediscoverFor: [] }); + assert.deepStrictEqual(settingsChangeEffects(affecting('problemMatching'), () => 'gcc'), { clearDiagnostics: false, rediscoverFor: [] }); + }); + + test('the problem matching mode is read only when problem matching changed', () => { + let reads = 0; + settingsChangeEffects(affecting('projects'), () => { reads++; return 'disabled'; }); + assert.strictEqual(reads, 0); + }); +}); + +suite('sourceEventEffect', () => { + const file = (isTestFile: boolean, mayBeTestFile: boolean) => ({ isTestFile: () => isTestFile, mayBeTestFile: () => mayBeTestFile }); + + test('a changed file is reparsed, which ignores anything not a discovered test file', () => { + assert.strictEqual(sourceEventEffect('changed', file(true, true)), 'reparse'); + assert.strictEqual(sourceEventEffect('changed', file(false, false)), 'reparse'); + }); + + // Only `ceedling files:test` knows the full list of test files. + test('a new file that may be a test file rediscovers; any other new file does nothing', () => { + assert.strictEqual(sourceEventEffect('created', file(false, true)), 'rediscover'); + assert.strictEqual(sourceEventEffect('created', file(false, false)), 'none'); + }); + + test('a deleted test file rediscovers; any other deleted file does nothing', () => { + assert.strictEqual(sourceEventEffect('deleted', file(true, true)), 'rediscover'); + assert.strictEqual(sourceEventEffect('deleted', file(false, true)), 'none'); + }); +}); + +suite('watcherChanges', () => { + test('watchers no longer wanted go, and newly wanted ones come, the rest untouched', () => { + assert.deepStrictEqual(watcherChanges(['/a', '/b'], new Set(['/b', '/c'])), { stale: ['/a'], added: ['/c'] }); + }); + + test('nothing changes when the wanted set is already watched', () => { + assert.deepStrictEqual(watcherChanges(['/a'], new Set(['/a'])), { stale: [], added: [] }); + }); +}); + +// A manual clock, so tests control when timers fire. +function manualTimers() { + let now = 0; + let nextId = 1; + const pending = new Map void }>(); + return { + timers: { + setTimeout: (action: () => void, ms: number) => { const id = nextId++; pending.set(id, { at: now + ms, action }); return id; }, + clearTimeout: (id: number | undefined) => { if (id !== undefined) pending.delete(id); }, + }, + advance(ms: number) { + now += ms; + for (const [id, timer] of [...pending].sort((a, b) => a[1].at - b[1].at)) { + if (timer.at <= now) { pending.delete(id); timer.action(); } + } + }, + get pending() { return pending.size; }, + }; +} + +suite('Debouncer', () => { + test('runs the latest action for a key once its calls pause', () => { + const clock = manualTimers(); + const debouncer = new Debouncer(400, clock.timers); + const ran: string[] = []; + debouncer.schedule('a.c', () => ran.push('first')); + clock.advance(300); + debouncer.schedule('a.c', () => ran.push('second')); + clock.advance(300); + assert.deepStrictEqual(ran, []); + clock.advance(100); + assert.deepStrictEqual(ran, ['second']); + }); + + test('keys wait independently', () => { + const clock = manualTimers(); + const debouncer = new Debouncer(400, clock.timers); + const ran: string[] = []; + debouncer.schedule('a.c', () => ran.push('a')); + clock.advance(200); + debouncer.schedule('b.c', () => ran.push('b')); + clock.advance(200); + assert.deepStrictEqual(ran, ['a']); + clock.advance(200); + assert.deepStrictEqual(ran, ['a', 'b']); + }); + + test('dispose cancels everything pending', () => { + const clock = manualTimers(); + const debouncer = new Debouncer(400, clock.timers); + const ran: string[] = []; + debouncer.schedule('a.c', () => ran.push('a')); + debouncer.schedule('b.c', () => ran.push('b')); + debouncer.dispose(); + clock.advance(1000); + assert.deepStrictEqual(ran, []); + assert.strictEqual(clock.pending, 0); + }); +}); From b523a1f4f48dc517e232ae701c9cc76476a8c997 Mon Sep 17 00:00:00 2001 From: Michael Karlesky Date: Fri, 23 Oct 2026 14:34:00 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20Describe=20the=20extracted?= =?UTF-8?q?=20modules=20and=20the=20thin=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Development.md's module table gains testReporting, debugLaunch, watchPolicy, and diagnosticsStore, and says where an adapter's decisions belong. Staying current, Reporting, and Debugging name the functions that now make each decision, and the coverage notes say why the adapters still show 0%. Co-Authored-By: Claude Opus 5.5 --- docs/Development.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/Development.md b/docs/Development.md index d6b4566..f4a0762 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -31,7 +31,7 @@ Two reports point at what to fix next: lint findings and unit-test coverage gaps - Run `just coverage`, or `npm run test:coverage`. It prints a table per `src/*.ts` file: statement, branch, function, and line coverage, plus the uncovered line numbers. - `coverage/index.html` shows each file with its uncovered lines highlighted. `coverage/lcov.info` and `coverage/coverage-summary.json` serve other tools. -- Only unit tests are measured. VS Code adapters such as `testController.ts` and `main.ts` show 0% until their logic moves into tested modules or integration tests cover it. +- Only unit tests are measured. The VS Code adapters, `testController.ts`, `problemMatcher.ts`, `main.ts`, and `logger.ts`, show 0%. Their decisions live in tested pure modules, and what remains is wiring to the editor. The unit tests compile into `out-test/`, apart from the extension bundle in `out/`. The test build compiles every file in `src/`, so a file no test touches still appears in the coverage table. @@ -46,13 +46,13 @@ The extension connects Ceedling’s command line to VS Code’s native Testing A ### Orientation -The code has two layers. Most modules have no `vscode` dependency and are unit-tested in plain Node. A thin layer of VS Code adapters wires them to the editor. +The code has two layers. Most modules have no `vscode` dependency and are unit-tested in plain Node. A thin layer of VS Code adapters wires them to the editor. A decision an adapter makes belongs in a pure module, where a unit test can reach it. | Module | Layer | Responsibility | |---|---|---| | `src/main.ts` | VS Code | Activation, commands, one controller per workspace folder | -| `src/testController.ts` | VS Code | TestItems, run and debug profiles, reporting into a `TestRun` | -| `src/problemMatcher.ts` | VS Code | The Problems panel’s `DiagnosticCollection`, keyed by run scope | +| `src/testController.ts` | VS Code | TestItems, run and debug profiles, watchers, and the `TestRun` sink | +| `src/problemMatcher.ts` | VS Code | Publishes diagnostics to the Problems panel’s `DiagnosticCollection` | | `src/logger.ts` | VS Code | The “Ceedling Explorer” log output channel | | `src/ceedlingEngine.ts` | Pure | Orchestration: discover, run, debug build, clean, clobber | | `src/ceedlingProjectConfig.ts` | Pure | Settings → projects; resolved configuration → `ProjectState` | @@ -60,7 +60,11 @@ The code has two layers. Most modules have no `vscode` dependency and are unit-t | `src/unityTestDiscovery.ts` | Pure | Test functions and cases in source, named as Unity names them | | `src/testResults.ts` | Pure | The CppUnit report, mapped onto discovered ids | | `src/runPlan.ts` | Pure | A run request → the fewest Ceedling invocations | -| `src/testModel.ts` | Pure | The test tree types and the `RunReporter` interface | +| `src/testModel.ts` | Pure | The test tree types, the `RunReporter` interface, and the controller’s label | +| `src/testReporting.ts` | Pure | A `RunReporter` that resolves ids to items and builds plain messages for a `RunSink` | +| `src/debugLaunch.ts` | Pure | The file a debug request debugs, and its launch configuration | +| `src/watchPolicy.ts` | Pure | What settings changes, file events, and edits make the controller do | +| `src/diagnosticsStore.ts` | Pure | Each run scope’s diagnostics, shown by file without duplicates | | `src/singleFlight.ts` | Pure | Coalesces overlapping discovery requests into one rerun | | `src/concurrency.ts` | Pure | Grouped concurrent mapping, a lock per key, and a limit shared by every caller | | `src/ceedlingOutputParsing.ts` | Pure | Ceedling stdout, report names, and pretty labels | @@ -88,9 +92,9 @@ The engine replaces its `projects` map only after every project loads. A failed The controller keeps the tree current without polling. Its refreshes go through a `SingleFlight`, so startup, watchers, and settings changes arriving together cause one discovery plus at most one rerun. - **Configuration files.** `CeedlingEngine.configurationFiles()` lists every file a project’s configuration can come from, existing or not: both default project files, any alternate project file, and file-path mixins. A `FileSystemWatcher` on each triggers discovery, so creating a project file in an empty folder starts one. -- **Test sources.** A watcher on each project directory’s `**/*.c` reparses a changed test file alone, through `rediscoverFile()`, with no Ceedling call. A created file that `mayBeTestFile()` accepts, or a deleted test file, triggers discovery, since only `ceedling files:test` knows the full list. -- **Unsaved edits.** `onDidChangeTextDocument` reparses an open test file from its buffer once typing pauses. -- **Settings.** A change to a setting in `DISCOVERY_SETTINGS` triggers discovery. Others apply on the next run. +- **Test sources.** A watcher on each project directory’s `**/*.c` reparses a changed test file alone, through `rediscoverFile()`, with no Ceedling call. A created file that `mayBeTestFile()` accepts, or a deleted test file, triggers discovery, since only `ceedling files:test` knows the full list. `sourceEventEffect()` makes that choice. +- **Unsaved edits.** `onDidChangeTextDocument` reparses an open test file from its buffer once typing pauses, through a `Debouncer` keyed by file. +- **Settings.** `settingsChangeEffects()` decides. A change to a setting that shapes discovery, such as `projects` or `mixins`, triggers discovery. Turning problem matching off clears its diagnostics. Others apply on the next run. The TestController itself exists only while discovery finds a project, so a folder without one adds nothing to the Testing view. @@ -100,7 +104,7 @@ A run turns the request into a plan, then reports each plan entry’s results. - **Planning.** `buildRunPlan(projects, include, exclude)` returns `RunPlanEntry`s of three kinds. A `project` entry runs `test:all`. A `file` entry runs `test:`. A `function` entry adds `--test-case=`. Excluded items narrow the plan to function granularity at most. The reporter drops their results. - **Running.** `runTests()` marks every id started, then takes its project directory’s lock. `buildAndRun()` deletes stale reports in `artifacts/test` and `artifacts/gcov`, runs Ceedling, appends the transcript to the run’s output, updates diagnostics, and reads the newest report. -- **Reporting.** `testResults(report, leafIds)` yields `passed`, `skipped`, `failed`, and `unreported` results. An `unreported` id gets an error naming the likely cause: discovery and Ceedling disagree about its name. With no report at all, every id errors, and the most severe compiler diagnostic leads the message. +- **Reporting.** `testResults(report, leafIds)` yields `passed`, `skipped`, `failed`, and `unreported` results. An `unreported` id gets an error naming the likely cause: discovery and Ceedling disagree about its name. With no report at all, every id errors, and the most severe compiler diagnostic leads the message. The controller’s reporter, from `createRunReporter()`, maps each id to its TestItem. It drops results for excluded items and, with a warning, for unknown ids, and turns a crash’s gdb log path into a link. - **Edge cases.** A `--test-case` run whose report also names another function, from Ceedling’s substring filter, reruns the whole file. A cancelled run kills only its own process tree, through `CeedlingRunner`, and marks its tests skipped. **One Ceedling command per project directory at a time.** Every Ceedling command that loads a project, `dumpconfig` and `files:test` included, rewrites the vendored Unity and CMock sources under `build/vendor/` in place. A second command in the same directory can leave a build compiling a half-written header. So `dumpconfig`, `files:test`, runs, debug builds, and clean/clobber each hold their directory’s lock (`KeyedLock`, via `inDirectory()`). `ceedling version` loads no project and needs no lock. Different directories proceed independently, so builds in separate projects may overlap. @@ -109,7 +113,7 @@ Diagnostics are keyed by run scope: the project key for a project run, `projectK ### Debugging -`handleDebug()` debugs one test file. `compileForDebug()` runs the file exactly as a run does and reports its real results. It then returns the executable’s path relative to Ceedling’s test output directory, `name/name`. `resolveLaunchConfig()` splices that path into every `${command:ceedlingExplorer.debugTestExecutable}` in the named launch configuration. A unique `__ceedlingDebugRequest` value in that configuration identifies its debug session among any others. +`handleDebug()` debugs one test file. `compileForDebug()` runs the file exactly as a run does and reports its real results. It then returns the executable’s path relative to Ceedling’s test output directory, `name/name`. `resolveLaunchConfig()` in `debugLaunch.ts` splices that path into every `${command:ceedlingExplorer.debugTestExecutable}` in a copy of the named launch configuration. A unique `__ceedlingDebugRequest` value in that configuration identifies its debug session among any others. ### Test seams