From be6ff425b621a44d9f4147e17c7b38963d795cdf Mon Sep 17 00:00:00 2001 From: Michael Karlesky Date: Sat, 24 Oct 2026 10:56:53 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20Lock=20Ceedling=20work=20by?= =?UTF-8?q?=20build=20root=20too;=20keep=20same-named=20files'=20coverage?= =?UTF-8?q?=20counters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects in different directories sharing a build root, e.g. through a common mixin setting :project: :build_root:, ran builds at once and read each other's reports. Every Ceedling command now holds a lock keyed by the resolved build root as well as the project directory, taken in sorted order through KeyedLock.runAll(). A --test-case collision's whole-file fallback cleared the counters of every test file with the same name. It now clears only the counters beneath the file's own directory within its test path, as Ceedling 1.2.0 and later lay them out. The --force-test-rerun comments give the reason that holds on Ceedling 1.2.1: delta builds ignore deleted coverage counters. The same-named-file coverage warning, README, KnownIssues, and CHANGELOG say what each Ceedling version does with an ambiguous name, and name 1.2.1 as supported. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- README.md | 8 +- docs/Development.md | 9 +- docs/KnownIssues.md | 2 +- src/ceedlingEngine.ts | 115 +++++++++++++++-------- src/ceedlingProjectConfig.ts | 4 +- src/concurrency.ts | 12 ++- tests/unit/ceedlingEngine.test.ts | 150 +++++++++++++++++++++++++++--- tests/unit/concurrency.test.ts | 32 +++++++ 9 files changed, 272 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 988487d..2ae24ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ This document is the terse, itemized record of what changed in each release. Its * A workspace folder without a Ceedling project no longer adds an empty _Ceedling_ entry to the _Testing_ view. With several workspace folders, each entry names its folder. * Discovery checks the Ceedling version in each project’s own directory, so a project’s `:project` ↳ `:which_ceedling` is respected. It no longer runs `ceedling summary`. * Discovery is faster in workspaces with several project directories or many test files. Projects in different directories load at the same time, and test files are read in parallel. Reading files no longer blocks VS Code’s extension host. -* Ceedling commands in different project directories may now run at the same time, e.g. a run in one project while another rediscovers. Commands within one project directory still run one at a time. +* Ceedling commands in different project directories may now run at the same time, e.g. a run in one project while another rediscovers. Commands within one project directory, or in projects sharing a build directory, still run one at a time. ## 🌟 Added @@ -25,6 +25,7 @@ This document is the terse, itemized record of what changed in each release. Its * Same-named test files in different directories (e.g. `test/unit/test_foo.c` and `test/integration/test_foo.c`) each run, report, and debug correctly. * Project configuration is read through `ceedling dumpconfig --stdout`, without a temporary file. * Every run reruns its tests. Ceedling 1.2.0’s delta builds otherwise reuse cached results that ignore which single test a run selected. +* Support for Ceedling 1.2.1, whose `gcov:` tasks take a test file’s path. Coverage runs of same-named test files in different directories each run the right file. * The `report_tests_log_factory` plugin’s `cppunit` report, which this extension reads results from, is now enabled automatically by a bundled [mixin](README.md#mixins). Enabling it in `project.yml` is no longer required. * New `ceedlingExplorer.mixins` and `ceedlingExplorer.projects[].mixins` settings apply Ceedling mixins, for toolchain targets, CI overrides, or other configuration, without editing `project.yml`. See [Mixins](README.md#mixins). * The _Testing_ view updates as you edit a test file, before you save. Adding or removing a test file, or editing `project.yml` or a mixin file, rediscovers the project without a manual refresh. diff --git a/README.md b/README.md index 35fc603..09b8c99 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Requires VS Code 1.88.0 or later. ## Ceedling -Requires Ceedling 1.0.0 or later. Ceedling 1.0.0 through 1.2.0 are supported. [Code coverage](#code-coverage) also needs Ceedling’s gcov plugin and a coverage report tool. +Requires Ceedling 1.0.0 or later. Ceedling 1.0.0 through 1.2.1 are supported. [Code coverage](#code-coverage) also needs Ceedling’s gcov plugin and a coverage report tool. _[Ceedling documentation](https://throwtheswitch.github.io/Ceedling/latest/)_ @@ -112,12 +112,12 @@ A mixin keeps coverage settings out of `project.yml`. Save the YAML above as, e. A coverage run uses Ceedling’s `gcov:` tasks in place of `test:` tasks. Test results appear in the _Testing_ view exactly as for a plain run. -- **One run, one measurement.** Each coverage run shows only what it executed. The tools report every coverage counter in the build directory, so the extension clears the counters earlier runs left before each coverage run. To accumulate coverage across runs instead, as Ceedling itself does until `ceedling clean` or `clobber`, set `ceedlingExplorer.coverageResetsCounters` to `false`. Two coverage runs at once in the same project share its counters, so each may show some of the other’s coverage. +- **One run, one measurement.** Each coverage run shows only what it executed. The tools report every coverage counter in the build directory, so the extension clears the counters earlier runs left before each coverage run. To accumulate coverage across runs instead, as Ceedling itself does until `ceedling clean` or `clobber`, set `ceedlingExplorer.coverageResetsCounters` to `false`. Two coverage runs at once in the same build directory share its counters, so each may show some of the other’s coverage. - **Which report.** With both tools enabled, the extension reads gcovr’s report. - **Deferred reports.** With `:gcov:` ↳ `:report_task: TRUE`, reports come only from Ceedling’s `report:gcov` task. A coverage run adds that task to its own command line. - **Several projects.** A run spanning projects shows their coverage together. A source file that several projects test shows their combined hits. - **Projects without the plugin.** A coverage run still runs their tests, and a notification says why they have no coverage. -- **Same-named test files.** Before Ceedling 1.2.1, `gcov:` tasks take a test file’s name without its directory, so they can’t tell apart same-named test files in different directories. The extension warns in its log when that applies. Ceedling 1.2.1 and later take the file’s path, as `test:` tasks do. +- **Same-named test files.** Before Ceedling 1.2.1, `gcov:` tasks take a test file’s name without its directory, so they can’t tell apart same-named test files in different directories. Ceedling 1.2.0 then stops with an ambiguous file reference error, and earlier versions may run the other file. The extension warns in its log when that applies. Ceedling 1.2.1 and later take the file’s path, as `test:` tasks do. ## What each tool reports @@ -157,7 +157,7 @@ Read each property below as `ceedlingExplorer.`. | `.prettyTestLabel` | Shortens each test’s label in the _Testing_ view by dropping its prefix, e.g. `test_BlinkTaskShouldToggleLed` becomes `BlinkTaskShouldToggleLed`. By default it is `true`.

Inactive:
prettyTestLabelInactive

Active:
prettyTestLabelActive| | `.prettyTestFileLabel` | Shortens each test file’s label by dropping its path, prefix, and extension, e.g. `test/LEDs/test_BlinkTask.c` becomes `BlinkTask`. By default it is `true`.

Inactive:
prettyTestFileLabelInactive

Active:
prettyTestFileLabelActive | | `.testCommandArgs` | The command line arguments that run a test file. The first must contain the `${TEST_ID}` tag, which becomes the test file. With Ceedling 1.2.0 and later that is the file’s project-relative path (e.g. `test/unit/test_foo.c`), which tells same-named test files apart. With earlier versions it is the file’s name alone. By default it is `["test:${TEST_ID}"]`. Coverage runs use `.coverageCommandArgs` instead. | -| `.coverageCommandArgs` | The command line arguments that run a test file with [coverage](#code-coverage). The first must contain the `${TEST_ID}` tag, which becomes the test file’s name alone, as Ceedling’s `gcov:` tasks require. By default it is `["gcov:${TEST_ID}"]`. | +| `.coverageCommandArgs` | The command line arguments that run a test file with [coverage](#code-coverage). The first must contain the `${TEST_ID}` tag, which becomes the test file. With Ceedling 1.2.1 and later that is the file’s project-relative path, as for `.testCommandArgs`. With earlier versions it is the file’s name alone, as their `gcov:` tasks require. By default it is `["gcov:${TEST_ID}"]`. | | `.coverageResetsCounters` | Whether each [coverage](#code-coverage) run clears the coverage counters earlier runs left, so it shows only what it executed. By default it is `true`. | | `.problemMatching` | Compiler and linker problem matching. See [Problem matching](#problem-matching). | | `.ansiEscapeSequencesRemoved` | Whether ANSI escape sequences are removed from Ceedling’s output. By default it is `true`. | diff --git a/docs/Development.md b/docs/Development.md index 822a447..e3f714d 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -104,11 +104,11 @@ The TestController itself exists only while discovery finds a project, so a fold 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. +- **Running.** `runTests()` marks every id started, then takes its project’s locks. `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. 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. +**One Ceedling command per project directory, and per build root, at a time.** Before Ceedling 1.2.1, 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 can then leave a build compiling a half-written header. Builds sharing a build root also share its reports and coverage counters, as projects in different directories do when a common mixin sets `:project` ↳ `:build_root`. So `dumpconfig`, `files:test`, runs, debug builds, and clean/clobber each hold two locks, through `exclusively()`. One is keyed by the project directory. The other is keyed by the resolved build root, once a discovery has found it. A project no discovery has loaded yet holds only its directory’s lock, so before 1.2.1 its first `dumpconfig` can still rewrite a shared build root’s vendored sources under another project’s build. `KeyedLock.runAll()` takes the locks in sorted order, so two callers never deadlock. `ceedling version` loads no project and needs no lock. Projects apart in both directory and build root proceed independently, so their builds may overlap. Diagnostics are keyed by run scope: the project key for a project run, `projectKey::fileId` for a file or function run. A project run replaces its own entry and drops its files’ entries. A file run replaces its own entry and drops the project entry’s diagnostics located in that file. @@ -121,7 +121,7 @@ Diagnostics are keyed by run scope: the project key for a project run, `projectK A coverage run is an ordinary run with a `RunMode` of `coverage`. The controller creates a fresh one per request with `coverageMode()`. The Coverage profile exists while `CeedlingEngine.coverageEnabled()` is true, i.e. while some project enables Ceedling’s gcov plugin. `syncCoverageProfile()` creates or disposes it after each discovery. - **Configuration.** `coverageState()` reads the resolved configuration. It says whether reports wait for `report:gcov`. It also lists where each enabled utility writes its Cobertura report, relative to the build root, gcovr’s first. ReportGenerator writes `ReportGenerator/Cobertura.xml`, or `ReportGenerator/Cobertura/Cobertura.xml` with several report types. `coverageProblem()` explains a project that writes no Cobertura report. -- **Building.** `commandArgs()` uses `coverageCommandArgs`. Before Ceedling 1.2.1 it passes the bare file name, since the `gcov:` task prepends the test file prefix to a name lacking it. From 1.2.1 it passes the path (`capabilitiesOf().pathQualifiedCoverageTasks`). It appends `report:gcov` when reports wait for that task. Under the directory lock, `prepareCoverage()` deletes stale Cobertura reports. Once per build directory per request, it also deletes the `.gcda` counters, and the `.gcov` files gcov writes from them, under `/gcov/out`, unless `coverageResetsCounters` is off. A `--test-case` collision clears only its test’s own counters before the whole-file fallback. `readCoverage()` reads the first report present before the lock is released. +- **Building.** `commandArgs()` uses `coverageCommandArgs`. Before Ceedling 1.2.1 it passes the bare file name, since the `gcov:` task prepends the test file prefix to a name lacking it. From 1.2.1 it passes the path (`capabilitiesOf().pathQualifiedCoverageTasks`). It appends `report:gcov` when reports wait for that task. Under the project’s locks, `prepareCoverage()` deletes stale Cobertura reports. Once per build directory per request, it also deletes the `.gcda` counters, and the `.gcov` files gcov writes from them, under `/gcov/out`, unless `coverageResetsCounters` is off. A `--test-case` collision clears only its test’s own counters before the whole-file fallback. From 1.2.0 those sit under the test file’s directory within its test path, e.g. `gcov/out/unit/test_foo/`, and `clearTestCounters()` takes the deepest directory named for the file whose parents end the file’s own path, so same-named test files elsewhere keep theirs. `readCoverage()` reads the first report present before the lock is released. - **Paths.** Cobertura names files relative to one of its `` roots, or absolutely. `reportedFiles()` gives each file a candidate per source, resolved against the project directory, where Ceedling runs the tools. The engine keeps the first candidate that exists. - **Showing.** Each build that produced test results reports a `CoverageOutcome` through `RunReporter.coverage()`. A failed build reports one only for a configuration that can’t give coverage. `mergeCoverage()` keeps the last report per report path, and adds up reports from separate build directories. The controller adds a `FileCoverage` per file, and keeps its details in a `WeakMap` that `loadDetailedCoverage` reads. A `missing` outcome becomes a notification and a line in the run’s output. @@ -250,7 +250,7 @@ See [tests/manual/README.md](../tests/manual/README.md) for the `project.yml` se ## 5. Manual Testing Plan -This checklist confirms the extension’s behavior in a real VS Code, where unit and integration tests can’t reach. Run it before a release, against the final code on the integration branch, on Ceedling 1.0.0, 1.1.10, and 1.2.0. +This checklist confirms the extension’s behavior in a real VS Code, where unit and integration tests can’t reach. Run it before a release, against the final code on the integration branch, on Ceedling 1.0.0, 1.1.10, 1.2.0, and 1.2.1. Every step runs against [`tests/manual/`](../tests/manual/), opened as its own folder in the Development Host. @@ -278,6 +278,7 @@ Every step runs against [`tests/manual/`](../tests/manual/), opened as its own f - [ ] **Coverage with gcovr.** Without a coverage mixin, the Testing view offers no Run with Coverage. Add `"ceedlingExplorer.mixins": ["mixins/coverage-gcovr.yml"]` to `tests/manual/.vscode/settings.json`, and Run with Coverage appears. Run `test_calculator.c` with coverage. Its results match a plain run. The Test Coverage panel lists `src/Calculator.c` with a percentage. Inline coverage marks its executed and unexecuted lines. Keep the setting for the next checks. - [ ] **Coverage of one run.** Run with coverage a single test of `test_calculator.c`, then the whole file. Each run’s hit counts reflect only that run. Set `"ceedlingExplorer.coverageResetsCounters": false`, and repeat the file run twice. The hit counts grow. Revert the setting after. - [ ] **Coverage with ReportGenerator.** With ReportGenerator installed, use `mixins/coverage-reportgenerator.yml` in place of the gcovr mixin. Coverage shows as with gcovr, including function rows in the Test Coverage panel. +- [ ] **Coverage of same-named test files.** Add `test/alpha/test_dup.c` and `test/beta/test_dup.c`, each with one test calling a different function of `src/Calculator.c`. On 1.2.1, Run with Coverage each file. Each passes and shows only its own function as executed. On 1.2.0, the same run errors with Ceedling’s ambiguous file reference error, and the Ceedling Explorer log warns about the ambiguous name. Revert after. - [ ] **No Cobertura report.** Use `mixins/coverage-no-cobertura.yml` in place of the gcovr mixin. Run with Coverage still runs the tests. A notification says to add `Cobertura` to `:gcov:` ↳ `:reports:`. Remove the mixin setting after, and Run with Coverage disappears. ## 6. The Sidecar diff --git a/docs/KnownIssues.md b/docs/KnownIssues.md index a61b851..3e92c89 100644 --- a/docs/KnownIssues.md +++ b/docs/KnownIssues.md @@ -6,7 +6,7 @@ This document lists the problems currently known, grouped by the Ceedling versio ## Ceedling versions before 1.2.1 -1. A coverage run can’t tell apart same-named test files in different directories, e.g. `test/unit/test_foo.c` and `test/integration/test_foo.c`. Ceedling’s `gcov:` tasks before 1.2.1 take a test file’s name without its directory, so Ceedling may run the other file. The extension warns in its log when a coverage run is ambiguous this way. Plain runs tell the files apart from Ceedling 1.2.0. The fix is tracked in [Ceedling#1362](https://github.com/ThrowTheSwitch/Ceedling/issues/1362). +1. A coverage run can’t tell apart same-named test files in different directories, e.g. `test/unit/test_foo.c` and `test/integration/test_foo.c`. Ceedling’s `gcov:` tasks before 1.2.1 take a test file’s name without its directory. Ceedling 1.2.0 then stops with an ambiguous file reference error, and earlier versions may run the other file. The extension warns in its log when a coverage run is ambiguous this way. Plain runs tell the files apart from Ceedling 1.2.0. The fix is tracked in [Ceedling#1362](https://github.com/ThrowTheSwitch/Ceedling/issues/1362). ## Ceedling versions before 1.2.0 diff --git a/src/ceedlingEngine.ts b/src/ceedlingEngine.ts index 264acee..c7ba8ba 100644 --- a/src/ceedlingEngine.ts +++ b/src/ceedlingEngine.ts @@ -116,6 +116,10 @@ export type EngineDeps = { type LoadedProject = CeedlingProject & { capabilities: CeedlingCapabilities, state: ProjectState, files: CeedlingFileNode[] }; +function isLoaded(project: CeedlingProject): project is LoadedProject { + return 'state' in project; +} + type LoadOutcome = | { kind: 'loaded', project: LoadedProject } | { kind: 'error', message: string } @@ -157,11 +161,12 @@ export class CeedlingEngine { // Path rules follow `platform` rather than the host's, as ceedlingProjectConfig's do. private readonly paths: typeof path.posix; private readonly tmpdir: string; - // One Ceedling command at a time per project directory. Every command that loads a project - // rewrites the Unity and CMock copies in its build directory, in place, so a command overlapping - // a build in the same directory can corrupt it. Different directories proceed independently. - // `ceedling version` loads no project and needs no lock. - private readonly directoryLocks = new KeyedLock(); + // One Ceedling command at a time per project directory and per build root. Before 1.2.1, every + // command that loads a project rewrites the Unity and CMock copies in its build root, in place, + // so a command overlapping a build can corrupt it. Builds sharing a build root also share its + // reports and counters. Work elsewhere proceeds independently. `ceedling version` loads no + // project and needs no lock. + private readonly locks = new KeyedLock(); // Every source read in the engine shares one limit, so overlapping discoveries and a burst of // watcher-driven reparses (e.g. a branch checkout) can't exhaust file descriptors together. private readonly reads = new Limiter(MAX_CONCURRENT_READS); @@ -281,8 +286,9 @@ export class CeedlingEngine { return []; } - // Projects load concurrently across directories, one at a time within a directory. Outcomes - // apply in settings order, so errors read the same however the loads interleave. + // Projects load concurrently across directories, one at a time within a directory, and + // within a build root the last discovery found. Outcomes apply in settings order, so errors + // read the same however the loads interleave. const outcomes = await mapGrouped(this.candidates, (project) => this.directoryOf(project), (project) => this.loadProject(project)); const loaded = new Map(); const errors: string[] = []; @@ -301,12 +307,12 @@ export class CeedlingEngine { throw new CeedlingLoadError(errors.join('\n')); } - // Listing runs Ceedling in each project directory, under its lock. A failed listing fails + // Listing runs Ceedling in each project directory, under its locks. A failed listing fails // discovery, rather than showing a project with no tests. Reading and parsing sources needs // no lock. const projects = [...loaded.values()]; const listings = await mapGrouped(projects, (project) => this.directoryOf(project), - (project) => this.inDirectory(project, () => this.listTestFiles(project))); + (project) => this.exclusively(project, () => this.listTestFiles(project))); const unlisted = projects.filter((_, i) => listings[i] === undefined); if (unlisted.length > 0) { throw new CeedlingLoadError(unlisted.map((project) => @@ -352,7 +358,7 @@ export class CeedlingEngine { `Ceedling's filter is a substring match, not exact. Falling back to a full-file run.` ); if (mode.kind === 'coverage') { - await this.inDirectory(project, () => this.clearTestCounters(project, file)); + await this.exclusively(project, () => this.clearTestCounters(project, file)); } await this.runFile(projectKey, file, reporter, token, mode); } @@ -379,7 +385,7 @@ export class CeedlingEngine { for (const id of ids) { reporter.started(id); } - return this.inDirectory(project, async () => { + return this.exclusively(project, async () => { const outcome = await this.buildAndRun(project, { kind: 'file', file }, TEST_MODE, this.commandArgs(project, file.id, TEST_MODE), reporter, token); if (outcome.cancelled) { leaves.forEach((id) => reporter.skipped(id)); @@ -428,13 +434,20 @@ export class CeedlingEngine { return project; } - // The key a project's directory groups and locks its work under. + // The key a project's directory groups its work under. private directoryOf(project: CeedlingProject): string { return pathKey(project.absPath, this.platform); } - private inDirectory(project: CeedlingProject, work: () => Promise): Promise { - return this.directoryLocks.run(this.directoryOf(project), work); + // Runs work holding the project's locks: its directory's, and its build root's once a + // discovery has resolved it. A rediscovery takes the build root the last discovery found, + // which any build still running uses. A project no discovery has loaded yet holds only its + // directory's, so before 1.2.1 its first dumpconfig can still rewrite a shared build root's + // vendored sources under another project's build. + private exclusively(project: CeedlingProject, work: () => Promise): Promise { + const loaded = isLoaded(project) ? project : this.projects.get(project.key); + const buildRoot = loaded ? [`build:${pathKey(this.buildPath(loaded), this.platform)}`] : []; + return this.locks.runAll([`directory:${this.directoryOf(project)}`, ...buildRoot], work); } // Checks one project's Ceedling and resolves its configuration. An unusable Ceedling stops all @@ -453,7 +466,7 @@ export class CeedlingEngine { `Remove or rename one, or name the one to use in ceedlingExplorer.projects[].path.`, }; } - const resolved = await this.inDirectory(project, () => this.resolveConfig(project, capabilities)); + const resolved = await this.exclusively(project, () => this.resolveConfig(project, capabilities)); if (resolved === undefined) { return { kind: 'error', @@ -546,7 +559,7 @@ export class CeedlingEngine { ); } - // Callers hold the project directory's lock. undefined when Ceedling failed, with its output logged. + // Callers hold the project's locks. undefined when Ceedling failed, with its output logged. private async listTestFiles(project: CeedlingProject): Promise { const result = await this.ceedling(['files:test'], { project }); if (result.error) { @@ -625,7 +638,7 @@ export class CeedlingEngine { for (const id of ids) { reporter.started(id); } - return this.inDirectory(project, async () => { + return this.exclusively(project, async () => { const outcome = await this.buildAndRun(project, scope, mode, args, reporter, token); if (outcome.cancelled) { leaves.forEach((id) => reporter.skipped(id)); @@ -653,15 +666,16 @@ export class CeedlingEngine { // Runs Ceedling and gathers everything a run reports from. Stale reports are deleted first, so // a report left by an earlier run can't pass as this one's. - // Callers hold the project directory's lock, so a coverage report read here is this build's. + // Callers hold the project's locks, so a coverage report read here is this build's. private async buildAndRun(project: LoadedProject, scope: RunScope, mode: RunMode, args: string[], reporter: RunReporter, token: Cancellation): Promise { await this.deleteStale(this.reportPaths(project)); if (mode.kind === 'coverage') { await this.prepareCoverage(project, mode.run); } - // Delta builds reuse cached results keyed without the --test-case filter. A filtered run - // could then report another run's tests, and a full run only a filtered run's. Rerunning - // the executable avoids both, while compilation stays incremental. + // Delta builds reuse cached results unless told to rerun, while compilation stays + // incremental either way. Before 1.2.1 their cache ignores the --test-case filter, so a + // filtered run could report another run's tests. In every version the cache also ignores + // deleted coverage counters, so a coverage run after a reset would report no coverage. const runArgs = project.capabilities.forceTestRerun ? [...args, '--force-test-rerun'] : args; const result = await this.ceedling(runArgs, { project, verbose: true, cancellation: token }); if (result.cancelled || token.isCancellationRequested) { @@ -738,7 +752,7 @@ export class CeedlingEngine { const segments = file.id.split(/[\\/]/); const name = (segments.pop() ?? file.id).replace(/\.[^.]*$/, ''); const executable = `${name}/${name}${project.state.executableExtension}`; - const outputDirectory = this.paths.resolve(project.absPath, project.state.buildRoot, 'test', 'out'); + const outputDirectory = this.buildPath(project, 'test', 'out'); const candidates = segments.map((_, i) => [...segments.slice(i), executable].join('/')).concat(executable); const mtimes = await Promise.all(candidates.map((subpath) => this.fs.mtimeMs(this.paths.resolve(outputDirectory, subpath)))); const [newest] = candidates @@ -753,7 +767,7 @@ export class CeedlingEngine { // A test run writes its report under artifacts/test, a gcov run under artifacts/gcov. private reportPaths(project: LoadedProject): string[] { - return ['test', 'gcov'].map((dir) => this.paths.resolve(project.absPath, project.state.buildRoot, 'artifacts', dir, project.state.reportFilename)); + return ['test', 'gcov'].map((dir) => this.buildPath(project, 'artifacts', dir, project.state.reportFilename)); } // Deletes whichever files exist. A file that won't go is logged, and the build goes ahead. @@ -767,7 +781,7 @@ export class CeedlingEngine { // Where the project's Cobertura report may appear, most preferred first. private coberturaPaths(project: LoadedProject): string[] { - return (project.state.coverage?.coberturaReports ?? []).map((report) => this.paths.resolve(project.absPath, project.state.buildRoot, report)); + return (project.state.coverage?.coberturaReports ?? []).map((report) => this.buildPath(project, report)); } // Clears what earlier runs left: stale Cobertura reports before every coverage build, and the @@ -781,16 +795,22 @@ export class CeedlingEngine { return; } run.resetDirectories.add(key); - await this.deleteCounters(project, () => true); + await this.deleteStale((await this.counters(project)).map((counter) => counter.file)); } - // Clears one test file's counters, under its own directory of gcov/out, before a whole-file run - // repeats the tests its filtered run just counted. + // Clears one test file's counters before a whole-file run repeats the tests its filtered run + // just counted. They sit beneath a directory named for the file, which from Ceedling 1.2.0 sits + // beneath the file's directory within its test path, e.g. gcov/out/unit/test_foo/ for + // test/unit/test_foo.c. That test path isn't known, so the deepest such directory whose parents + // end the file's own directory is the file's. Same-named test files elsewhere keep theirs. private async clearTestCounters(project: LoadedProject, file: CeedlingFileNode): Promise { - if (this.resetsCounters()) { - const name = this.paths.basename(file.id).replace(/\.[^.]*$/, ''); - await this.deleteCounters(project, (segments) => segments.includes(name)); + if (!this.resetsCounters()) { + return; } + const key = (segment: string) => pathKey(segment, this.platform); + const owned = (await this.counters(project)).map((counter) => ({ file: counter.file, depths: ownerDepths(counter.segments, file.id, key) })); + const deepest = owned.reduce((max, counter) => Math.max(max, ...counter.depths), -1); + await this.deleteStale(owned.filter((counter) => counter.depths.includes(deepest)).map((counter) => counter.file)); } private resetsCounters(): boolean { @@ -798,16 +818,21 @@ export class CeedlingEngine { } private countersDirectory(project: LoadedProject): string { - return this.paths.resolve(project.absPath, project.state.buildRoot, 'gcov', 'out'); + return this.buildPath(project, 'gcov', 'out'); + } + + // A path within the project's build root, `:project` ↳ `:build_root`, relative to its directory. + private buildPath(project: LoadedProject, ...segments: string[]): string { + return this.paths.resolve(project.absPath, project.state.buildRoot, ...segments); } - // Deletes gcov's counters (.gcda) and ReportGenerator's .gcov inputs, which gcov writes from them, - // wherever `within` accepts the file's directories beneath gcov/out. - private async deleteCounters(project: LoadedProject, within: (segments: string[]) => boolean): Promise { + // gcov's counters (.gcda) and ReportGenerator's .gcov inputs, which gcov writes from them, each + // with its directories beneath gcov/out. + private async counters(project: LoadedProject): Promise> { const directory = this.countersDirectory(project); const files = await this.reads.run(() => this.fs.listFiles(directory)); - await this.deleteStale(files.filter((file) => - /\.(gcda|gcov)$/.test(file) && within(this.paths.relative(directory, this.paths.dirname(file)).split(/[\\/]/)))); + return files.filter((file) => /\.(gcda|gcov)$/.test(file)) + .map((file) => ({ file, segments: this.paths.relative(directory, this.paths.dirname(file)).split(/[\\/]/) })); } // Why a project can't give coverage at all, or undefined when it can. @@ -879,7 +904,7 @@ export class CeedlingEngine { private async runInEveryProject(args: string[], token?: Cancellation): Promise { const results = await mapGrouped(this.candidates, (project) => this.directoryOf(project), - (project) => this.inDirectory(project, () => this.ceedling(args, { project, cancellation: token }))); + (project) => this.exclusively(project, () => this.ceedling(args, { project, cancellation: token }))); return results.every((result) => !result.error || result.cancelled); } @@ -899,7 +924,8 @@ export class CeedlingEngine { const namesakes = project.files.filter((f) => f.id !== testId && this.paths.basename(f.id.replace(/\\/g, '/')) === testFile); if (namesakes.length > 0) { this.logger.warn(`A coverage run of '${testId}' names it '${testFile}', which Ceedling before 1.2.1 can't tell apart from ` + - `${namesakes.map((f) => `'${f.id}'`).join(', ')}. Ceedling may run the other file.`); + `${namesakes.map((f) => `'${f.id}'`).join(', ')}. ` + + (project.capabilities.pathQualifiedTestTasks ? 'Ceedling 1.2.0 stops with an ambiguous file reference error.' : 'Ceedling may run the other file.')); } } const args = this.getConfiguration() @@ -945,3 +971,16 @@ export class CeedlingEngine { return result; } } + +// The depths among a counter's directories beneath gcov/out that are named for the test file and +// sit beneath a trailing part of the file's own directory. `key` folds case where the platform does. +function ownerDepths(segments: string[], testId: string, key: (segment: string) => string): number[] { + const directories = testId.split(/[\\/]/).slice(0, -1).map(key); + const name = key(testId.replace(/^.*[\\/]/, '').replace(/\.[^.]*$/, '')); + const keyed = segments.map(key); + return keyed.flatMap((segment, depth) => segment === name && endsWith(directories, keyed.slice(0, depth)) ? [depth] : []); +} + +function endsWith(whole: string[], tail: string[]): boolean { + return tail.length <= whole.length && tail.every((segment, i) => whole[whole.length - tail.length + i] === segment); +} diff --git a/src/ceedlingProjectConfig.ts b/src/ceedlingProjectConfig.ts index 51b1ed7..1224d20 100644 --- a/src/ceedlingProjectConfig.ts +++ b/src/ceedlingProjectConfig.ts @@ -69,8 +69,8 @@ export const ALTERNATE_DEFAULT_PROJECT_FILE = 'ceedling.yml'; export type CeedlingCapabilities = { // `dumpconfig --stdout` writes the resolved configuration to stdout instead of a file. dumpconfigStdout: boolean, - // Delta builds reuse cached results unless `--force-test-rerun` is given. Their cache - // ignores --test-case, so a filtered run can report another run's results without it. + // Delta builds reuse cached results unless `--force-test-rerun` is given. Before 1.2.1 their + // cache ignores --test-case. In every version it ignores deleted coverage counters. forceTestRerun: boolean, // A `test:` task accepts a project-relative path, which tells same-named test files apart. pathQualifiedTestTasks: boolean, diff --git a/src/concurrency.ts b/src/concurrency.ts index 4fdf6a1..124a065 100644 --- a/src/concurrency.ts +++ b/src/concurrency.ts @@ -41,8 +41,9 @@ async function run(entry: Entry, work: (item: T) => Promise, results } } -// Runs work one at a time per key, in arrival order, while different keys proceed independently. -// Each run waits for the work queued before it under its key, whether that succeeded or failed. +// Runs work one at a time per key, in arrival order per key, while different keys proceed +// independently. Each run waits for the work queued before it under its key, whether that +// succeeded or failed. export class KeyedLock { // The settled-when-free promise for each busy key: the end of its queue. private readonly tails = new Map>(); @@ -67,6 +68,13 @@ export class KeyedLock { } } } + + // Runs work holding every key. Keys are taken one at a time in sorted order, whatever order the + // caller names them, so two callers can never each hold a key the other waits for. + runAll(keys: readonly string[], work: () => Promise): Promise { + const [first, ...rest] = [...new Set(keys)].sort(); + return first === undefined ? work() : this.run(first, () => this.runAll(rest, work)); + } } // Runs at most `limit` pieces of work at once, across every caller, starting queued work in diff --git a/tests/unit/ceedlingEngine.test.ts b/tests/unit/ceedlingEngine.test.ts index ebba72b..be66ce3 100644 --- a/tests/unit/ceedlingEngine.test.ts +++ b/tests/unit/ceedlingEngine.test.ts @@ -1107,8 +1107,8 @@ suite('CeedlingEngine concurrent discovery', () => { (e: unknown) => e instanceof CeedlingLoadError && e.message.indexOf("'a'") < e.message.indexOf("'b'") && e.message.includes("'a'")); }); - // Every Ceedling command that loads a project rewrites the Unity copy in its build directory, so - // a configuration check during a build in the same directory could corrupt that build. + // Before Ceedling 1.2.1, every command that loads a project rewrites the Unity copy in its build + // directory, so a configuration check during a build in the same directory could corrupt it. test("a rediscovery's configuration check waits for a build in the same directory", async () => { const world = new FakeCeedling(); const { engine, file } = await discovered(world); @@ -1140,6 +1140,63 @@ suite('CeedlingEngine concurrent discovery', () => { await Promise.all(runs); }); + // A build's files and its report live under its build root, not its project directory, so + // projects sharing one, e.g. through a common mixin, would read each other's reports. + test('builds in different directories sharing a build root run one after the other', async () => { + const world = new FakeCeedling(); + world.resolvedConfig = `:project:\n :build_root: ../build\n${world.resolvedConfig}`; + for (const dir of ['a', 'b']) world.write(`${WS}/${dir}/test/test_calc.c`, 'void test_add(void) {}\n'); + const { engine, projects } = await discovered(world, { settings: twoProjects(world, 'a', 'b') }); + const held: Array<() => void> = []; + world.handlers.push((command) => /^ceedling test:/.test(command) ? new Promise((resolve) => { held.push(() => resolve({})); }) : undefined); + const runs = projects.map((p) => engine.runFile(p.projectKey, p.files[0], recordingReporter(), token())); + await settle(); + assert.strictEqual(world.commandsMatching(/^ceedling test:/).length, 1, 'one build at a time in the shared build root'); + held[0](); + await settle(); + await settle(); + assert.strictEqual(world.commandsMatching(/^ceedling test:/).length, 2); + held[1](); + await Promise.all(runs); + }); + + test("a rediscovery's configuration check waits for a build in another directory sharing its build root", async () => { + const world = new FakeCeedling(); + world.resolvedConfig = `:project:\n :build_root: /shared/build\n${world.resolvedConfig}`; + world.write(`${WS}/a/test/test_calc.c`, 'void test_add(void) {}\n'); + const { engine, projects } = await discovered(world, { settings: twoProjects(world, 'a', 'b') }); + let release!: () => void; + world.handlers.push((command) => /^ceedling test:/.test(command) ? new Promise((resolve) => { release = () => resolve({}); }) : undefined); + const run = engine.runFile('a', projects[0].files[0], recordingReporter(), token()); + await settle(); + const before = world.commands.filter((c) => /dumpconfig/.test(c.command) && c.cwd === `${WS}/b`).length; + const rediscovery = engine.discover(); + await settle(); + await settle(); + assert.strictEqual(world.commands.filter((c) => /dumpconfig/.test(c.command) && c.cwd === `${WS}/b`).length, before, "no dumpconfig in b while a's build runs"); + release(); + await Promise.all([run, rediscovery]); + assert.strictEqual(world.commands.filter((c) => /dumpconfig/.test(c.command) && c.cwd === `${WS}/b`).length, before + 1); + }); + + test('clean waits for a build in another directory sharing its build root', async () => { + const world = new FakeCeedling(); + world.resolvedConfig = `:project:\n :build_root: /shared/build\n${world.resolvedConfig}`; + world.write(`${WS}/a/test/test_calc.c`, 'void test_add(void) {}\n'); + const { engine, projects } = await discovered(world, { settings: twoProjects(world, 'a', 'b') }); + let release!: () => void; + world.handlers.push((command) => /^ceedling test:/.test(command) ? new Promise((resolve) => { release = () => resolve({}); }) : undefined); + const run = engine.runFile('a', projects[0].files[0], recordingReporter(), token()); + await settle(); + const clean = engine.clean(); + await settle(); + await settle(); + assert.deepStrictEqual(world.commandsMatching(/^ceedling clean/), [], 'no clean while the shared build root builds'); + release(); + await Promise.all([run, clean]); + assert.strictEqual(world.commandsMatching(/^ceedling clean/).length, 2); + }); + // Many watcher events at once, e.g. from a branch checkout, must not exhaust file descriptors. test('reads stay within one shared limit across rediscoveries and reparses', async () => { const world = new FakeCeedling(); @@ -1310,6 +1367,71 @@ suite('CeedlingEngine coverage', () => { assert.ok(world.files.has(otherTest)); }); + // From Ceedling 1.2.0, gcov/out nests each test's counters under the test file's directory + // within its test path, e.g. gcov/out/unit/test_calc/ for test/unit/test_calc.c. + test("a function run's whole-file fallback keeps the counters of same-named test files elsewhere", async () => { + const world = coverageWorld(); + world.version = '1.2.1'; + world.testFiles = ['test/test_calc.c', 'test/unit/test_calc.c', 'test/integration/test_calc.c']; + for (const f of world.testFiles) world.write(`${WS}/${f}`, 'void test_add(void) {}\n'); + const counters = { + root: `${WS}/build/gcov/out/test_calc/calc.gcda`, + unit: `${WS}/build/gcov/out/unit/test_calc/calc.gcda`, + integration: `${WS}/build/gcov/out/integration/test_calc/calc.gcda`, + integrationNested: `${WS}/build/gcov/out/integration/test_calc/sub/a/util.gcda`, + unitNested: `${WS}/build/gcov/out/unit/test_calc/sub/a/util.gcda`, + }; + world.handlers.unshift((command) => /--test-case/.test(command) + ? { writes: world.reportWrite(cppunit({ passed: ['test/integration/test_calc.c::test_add', 'test/integration/test_calc.c::test_add_more'] }), 'build', 'gcov') } + : undefined); + const { engine, projects } = await discovered(world); + const file = (id: string) => projects[0].files.find((f) => f.id === id)!; + const mode = coverageMode(); + // A first build spends the request's one counter reset, which would clear the counters below. + await engine.runFile('default', file('test/test_calc.c'), recordingReporter(), token(), mode); + for (const counter of Object.values(counters)) world.write(counter, ''); + const integration = file('test/integration/test_calc.c'); + await engine.runFunction('default', integration, integration.functions[0], recordingReporter(), token(), mode); + assert.strictEqual(world.commandsMatching(/^ceedling gcov:test\/integration\/test_calc\.c --force/).length, 1, 'the fallback ran'); + assert.deepStrictEqual(Object.entries(counters).filter(([, f]) => world.files.has(f)).map(([k]) => k), ['root', 'unit', 'unitNested']); + }); + + // Windows keeps the case a directory was created with, which may differ from the file's path. + test("on Windows, a function run's whole-file fallback finds its counters whatever their directories' case", async () => { + const world = new FakeCeedling(); + world.version = '1.2.1'; + world.resolvedConfig = gcovConfig(); + world.testFiles = ['test/unit/test_calc.c', 'test/integration/test_calc.c']; + world.write('C:\\ws\\project.yml', ':project: {}\n'); + for (const f of world.testFiles) world.write(`C:\\ws\\${f.replace(/\//g, '\\')}`, 'void test_add(void) {}\n'); + const own = 'C:\\ws\\build\\gcov\\out\\Unit\\Test_Calc\\calc.gcda'; + const namesake = 'C:\\ws\\build\\gcov\\out\\integration\\test_calc\\calc.gcda'; + world.handlers.push((command) => /^ceedling gcov:/.test(command) ? { writes: { + 'C:\\ws\\build\\artifacts\\gcov\\cppunit_tests_report.xml': cppunit({ passed: ['test/unit/test_calc.c::test_add', 'test/unit/test_calc.c::test_add_more'] }), + } } : undefined); + const engine = makeEngine(world, { folder: 'C:\\ws', platform: 'win32' }); + const [project] = await engine.discover(); + const unit = project.files.find((f) => f.id === 'test/unit/test_calc.c')!; + const mode = coverageMode(); + await engine.runFile('default', unit, recordingReporter(), token(), mode); + world.write(own, ''); + world.write(namesake, ''); + await engine.runFunction('default', unit, unit.functions[0], recordingReporter(), token(), mode); + assert.deepStrictEqual([own, namesake].map((f) => world.files.has(f)), [false, true]); + }); + + test("a function run's whole-file fallback keeps every counter when counters accumulate", async () => { + const world = coverageWorld(); + const counter = `${WS}/build/gcov/out/test_calc/calc.gcda`; + world.handlers.unshift((command) => /--test-case/.test(command) + ? { writes: { ...world.reportWrite(cppunit({ passed: ['test/test_calc.c::test_add', 'test/test_calc.c::test_add_more'] }), 'build', 'gcov'), [counter]: '' } } + : undefined); + const { engine, file } = await discovered(world, { settings: { coverageResetsCounters: false } }); + await engine.runFunction('default', file, file.functions[0], recordingReporter(), token(), coverageMode()); + assert.strictEqual(world.commandsMatching(/^ceedling gcov:/).length, 2, 'the fallback ran'); + assert.ok(world.files.has(counter)); + }); + test('each build directory has its counters cleared once per run', async () => { const world = coverageWorld(); for (const dir of ['a', 'b']) { @@ -1436,15 +1558,21 @@ suite('CeedlingEngine coverage', () => { }); // Before 1.2.1, Ceedling's gcov: tasks take a bare name, so they can't tell the two files apart. - test('a coverage run of one of two same-named test files warns that Ceedling may pick the other', async () => { - const world = coverageWorld(); - world.testFiles = ['test/a/test_calc.c', 'test/b/test_calc.c']; - for (const f of world.testFiles) world.write(`${WS}/${f}`, 'void test_add(void) {}\n'); - const logs: LogCall[] = []; - const { engine, file } = await discovered(world, { logs }); - await engine.runFile('default', file, recordingReporter(), token(), coverageMode()); - assert.ok(logs.some((l) => l.level === 'warn' && /test\/a\/test_calc\.c/.test(l.message) && /test\/b\/test_calc\.c/.test(l.message))); - }); + // Ceedling 1.2.0 then refuses the name as ambiguous, and earlier versions take the first match. + for (const [version, outcome] of [['1.2.0', /stops with an ambiguous file reference error/], ['1.1.10', /may run the other file/]] as const) { + test(`with Ceedling ${version}, a coverage run of one of two same-named test files warns what Ceedling does with its name`, async () => { + const world = coverageWorld(); + world.version = version; + world.testFiles = ['test/a/test_calc.c', 'test/b/test_calc.c']; + for (const f of world.testFiles) world.write(`${WS}/${f}`, 'void test_add(void) {}\n'); + const logs: LogCall[] = []; + const { engine, file } = await discovered(world, { logs }); + await engine.runFile('default', file, recordingReporter(), token(), coverageMode()); + const warnings = logs.filter((l) => l.level === 'warn' && /test\/a\/test_calc\.c/.test(l.message) && /test\/b\/test_calc\.c/.test(l.message)); + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0].message, outcome); + }); + } test('on Windows, counters and reports are found under the project, whatever the case of its path', async () => { const world = new FakeCeedling(); diff --git a/tests/unit/concurrency.test.ts b/tests/unit/concurrency.test.ts index e8d6228..cf9db18 100644 --- a/tests/unit/concurrency.test.ts +++ b/tests/unit/concurrency.test.ts @@ -93,6 +93,38 @@ suite('KeyedLock', () => { await lock.run('dir', async () => 1); assert.strictEqual(lock.size, 0); }); + + test('work under several keys waits for each, then holds them all', async () => { + const g = gate(); + const lock = new KeyedLock(); + const first = lock.run('x', () => g.work('x1')); + const both = lock.runAll(['x', 'y'], () => g.work('xy1')); + await settle(); + assert.deepStrictEqual(g.log.started, ['x1']); + await g.finish('x1'); + assert.deepStrictEqual(g.log.started, ['x1', 'xy1']); + const later = [lock.run('x', () => g.work('x2')), lock.run('y', () => g.work('y2'))]; + await settle(); + assert.deepStrictEqual(g.log.started, ['x1', 'xy1'], 'neither key is free while the work holding both runs'); + await g.finish('xy1'); + await g.finish('x2'); + await g.finish('y2'); + assert.deepStrictEqual(await Promise.all([first, both, ...later]), ['X1', 'XY1', 'X2', 'Y2']); + }); + + // Keys are taken in one order whatever order callers name them, so two callers never each hold + // a key the other waits for. + test('work naming the same keys in different orders, or one key twice, never deadlocks', async () => { + const g = gate(); + const lock = new KeyedLock(); + const runs = [lock.runAll(['a', 'b'], () => g.work('ab')), lock.runAll(['b', 'a', 'b'], () => g.work('ba'))]; + await settle(); + await g.finish('ab'); + await g.finish('ba'); + assert.deepStrictEqual(await Promise.all(runs), ['AB', 'BA']); + assert.strictEqual(await lock.runAll([], async () => 'none'), 'none'); + assert.strictEqual(lock.size, 0); + }); }); suite('Limiter', () => { From 67c6d79262bbfeb6543b640aecb352b956e14806 Mon Sep 17 00:00:00 2001 From: Michael Karlesky Date: Sat, 24 Oct 2026 11:16:32 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Name=20each=20test's?= =?UTF-8?q?=20build=20directory=20exactly=20as=20Ceedling=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Ceedling 1.2.0 a test builds under its file's directory within the longest test path holding it, e.g. unit/test_foo for test/unit/test_foo.c under +:test/**. testBuildName() mirrors Ceedling's PathMirror over the resolved :paths: :test:, with decorators and globs stripped, so a coverage fallback clears exactly its test's counters and a debug build finds exactly its executable, instead of matching trailing directories or taking the newest build. Co-Authored-By: Claude Opus 5.5 --- docs/Development.md | 4 +- src/ceedlingEngine.ts | 58 ++++++++----------- src/ceedlingProjectConfig.ts | 39 ++++++++++++- tests/unit/ceedlingEngine.test.ts | 71 +++++++++++++++++++++++- tests/unit/ceedlingProjectConfig.test.ts | 62 +++++++++++++++++++-- 5 files changed, 190 insertions(+), 44 deletions(-) diff --git a/docs/Development.md b/docs/Development.md index e3f714d..4e6da00 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -114,14 +114,14 @@ 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. Before 1.2.0 that is `name/name`. Ceedling 1.2.0 nests it under the test file’s directory within its test path, e.g. `unit/name/name` for `test/unit/name.c`. That test path isn’t known, so `executableSubpath()` tries each trailing part of the file’s directory and takes the newest build. `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. +`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. Before 1.2.0 that is `name/name`. Ceedling 1.2.0 nests it under the test file’s directory within its test path, e.g. `unit/name/name` for `test/unit/name.c`. `testBuildName()` in `ceedlingProjectConfig.ts` computes that directory as Ceedling does, from `ProjectState.testPaths`, the resolved `:paths` ↳ `:test`. Each test path loses its `+:` or `-:` decorator and everything from its first glob, and the longest one holding the file decides. `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. ### Coverage A coverage run is an ordinary run with a `RunMode` of `coverage`. The controller creates a fresh one per request with `coverageMode()`. The Coverage profile exists while `CeedlingEngine.coverageEnabled()` is true, i.e. while some project enables Ceedling’s gcov plugin. `syncCoverageProfile()` creates or disposes it after each discovery. - **Configuration.** `coverageState()` reads the resolved configuration. It says whether reports wait for `report:gcov`. It also lists where each enabled utility writes its Cobertura report, relative to the build root, gcovr’s first. ReportGenerator writes `ReportGenerator/Cobertura.xml`, or `ReportGenerator/Cobertura/Cobertura.xml` with several report types. `coverageProblem()` explains a project that writes no Cobertura report. -- **Building.** `commandArgs()` uses `coverageCommandArgs`. Before Ceedling 1.2.1 it passes the bare file name, since the `gcov:` task prepends the test file prefix to a name lacking it. From 1.2.1 it passes the path (`capabilitiesOf().pathQualifiedCoverageTasks`). It appends `report:gcov` when reports wait for that task. Under the project’s locks, `prepareCoverage()` deletes stale Cobertura reports. Once per build directory per request, it also deletes the `.gcda` counters, and the `.gcov` files gcov writes from them, under `/gcov/out`, unless `coverageResetsCounters` is off. A `--test-case` collision clears only its test’s own counters before the whole-file fallback. From 1.2.0 those sit under the test file’s directory within its test path, e.g. `gcov/out/unit/test_foo/`, and `clearTestCounters()` takes the deepest directory named for the file whose parents end the file’s own path, so same-named test files elsewhere keep theirs. `readCoverage()` reads the first report present before the lock is released. +- **Building.** `commandArgs()` uses `coverageCommandArgs`. Before Ceedling 1.2.1 it passes the bare file name, since the `gcov:` task prepends the test file prefix to a name lacking it. From 1.2.1 it passes the path (`capabilitiesOf().pathQualifiedCoverageTasks`). It appends `report:gcov` when reports wait for that task. Under the project’s locks, `prepareCoverage()` deletes stale Cobertura reports. Once per build directory per request, it also deletes the `.gcda` counters, and the `.gcov` files gcov writes from them, under `/gcov/out`, unless `coverageResetsCounters` is off. A `--test-case` collision clears only its test’s own counters before the whole-file fallback. They sit under the test’s build directory, from `testBuildName()`, e.g. `gcov/out/unit/test_foo/` from 1.2.0, so same-named test files elsewhere keep theirs. `readCoverage()` reads the first report present before the lock is released. - **Paths.** Cobertura names files relative to one of its `` roots, or absolutely. `reportedFiles()` gives each file a candidate per source, resolved against the project directory, where Ceedling runs the tools. The engine keeps the first candidate that exists. - **Showing.** Each build that produced test results reports a `CoverageOutcome` through `RunReporter.coverage()`. A failed build reports one only for a configuration that can’t give coverage. `mergeCoverage()` keeps the last report per report path, and adds up reports from separate build directories. The controller adds a `FileCoverage` per file, and keeps its details in a `WeakMap` that `loadDetailedCoverage` reads. A `missing` outcome becomes a notification and a line in the run’s output. diff --git a/src/ceedlingEngine.ts b/src/ceedlingEngine.ts index c7ba8ba..5a35542 100644 --- a/src/ceedlingEngine.ts +++ b/src/ceedlingEngine.ts @@ -42,6 +42,7 @@ import { type ProjectState, projectState, projectsFromSettings, + testBuildName, } from './ceedlingProjectConfig'; import { parseCoberturaReport, reportedFiles } from './coberturaReport'; import type { Logger } from './logger'; @@ -744,25 +745,22 @@ export class CeedlingEngine { return diagnostics.slice().sort((a, b) => a.diagnostic.severity - b.diagnostic.severity)[0]; } - // The executable's path under Ceedling's test output directory. Ceedling before 1.2.0 builds - // `name/name`. Ceedling 1.2.0 nests that under the test file's directory relative to its - // test path, e.g. `unit/name/name` for `test/unit/name.c`. That test path isn't known - // here, so each trailing part of the file's directory is tried, and the newest build wins. + // The executable's path under Ceedling's test output directory, `/`, e.g. + // `unit/test_foo/test_foo.out` for `test/unit/test_foo.c` from 1.2.0. A missing executable is + // logged, and its path returned all the same, for the launch to report. private async executableSubpath(project: LoadedProject, file: CeedlingFileNode): Promise { - const segments = file.id.split(/[\\/]/); - const name = (segments.pop() ?? file.id).replace(/\.[^.]*$/, ''); - const executable = `${name}/${name}${project.state.executableExtension}`; + const buildName = this.testBuildName(project, file); + const executable = `${buildName}/${buildName.replace(/^.*\//, '')}${project.state.executableExtension}`; const outputDirectory = this.buildPath(project, 'test', 'out'); - const candidates = segments.map((_, i) => [...segments.slice(i), executable].join('/')).concat(executable); - const mtimes = await Promise.all(candidates.map((subpath) => this.fs.mtimeMs(this.paths.resolve(outputDirectory, subpath)))); - const [newest] = candidates - .map((subpath, i) => ({ subpath, mtime: mtimes[i] })) - .filter((candidate): candidate is { subpath: string, mtime: number } => candidate.mtime !== undefined) - .sort((a, b) => b.mtime - a.mtime); - if (!newest) { - this.logger.warn(`executableSubpath: no built executable for '${file.id}' under ${outputDirectory}, assuming '${executable}'`); + if (await this.fs.mtimeMs(this.paths.resolve(outputDirectory, executable)) === undefined) { + this.logger.warn(`executableSubpath: no built executable for '${file.id}' at '${executable}' under ${outputDirectory}`); } - return newest?.subpath ?? executable; + return executable; + } + + // Where Ceedling builds the test file, beneath each build output directory. + private testBuildName(project: LoadedProject, file: CeedlingFileNode): string { + return testBuildName(file.id, project.state.testPaths, project.capabilities.nestedTestBuilds); } // A test run writes its report under artifacts/test, a gcov run under artifacts/gcov. @@ -799,18 +797,19 @@ export class CeedlingEngine { } // Clears one test file's counters before a whole-file run repeats the tests its filtered run - // just counted. They sit beneath a directory named for the file, which from Ceedling 1.2.0 sits - // beneath the file's directory within its test path, e.g. gcov/out/unit/test_foo/ for - // test/unit/test_foo.c. That test path isn't known, so the deepest such directory whose parents - // end the file's own directory is the file's. Same-named test files elsewhere keep theirs. + // just counted. They sit beneath the file's build directory in gcov/out, e.g. + // gcov/out/unit/test_foo/ for test/unit/test_foo.c, so same-named test files elsewhere keep + // theirs. Windows folds case, and keeps whatever case Ceedling created a directory with. private async clearTestCounters(project: LoadedProject, file: CeedlingFileNode): Promise { if (!this.resetsCounters()) { return; } const key = (segment: string) => pathKey(segment, this.platform); - const owned = (await this.counters(project)).map((counter) => ({ file: counter.file, depths: ownerDepths(counter.segments, file.id, key) })); - const deepest = owned.reduce((max, counter) => Math.max(max, ...counter.depths), -1); - await this.deleteStale(owned.filter((counter) => counter.depths.includes(deepest)).map((counter) => counter.file)); + const own = this.testBuildName(project, file).split('/').map(key); + const counters = await this.counters(project); + await this.deleteStale(counters + .filter((counter) => own.every((segment, i) => counter.segments[i] !== undefined && key(counter.segments[i]) === segment)) + .map((counter) => counter.file)); } private resetsCounters(): boolean { @@ -971,16 +970,3 @@ export class CeedlingEngine { return result; } } - -// The depths among a counter's directories beneath gcov/out that are named for the test file and -// sit beneath a trailing part of the file's own directory. `key` folds case where the platform does. -function ownerDepths(segments: string[], testId: string, key: (segment: string) => string): number[] { - const directories = testId.split(/[\\/]/).slice(0, -1).map(key); - const name = key(testId.replace(/^.*[\\/]/, '').replace(/\.[^.]*$/, '')); - const keyed = segments.map(key); - return keyed.flatMap((segment, depth) => segment === name && endsWith(directories, keyed.slice(0, depth)) ? [depth] : []); -} - -function endsWith(whole: string[], tail: string[]): boolean { - return tail.length <= whole.length && tail.every((segment, i) => whole[whole.length - tail.length + i] === segment); -} diff --git a/src/ceedlingProjectConfig.ts b/src/ceedlingProjectConfig.ts index 1224d20..8cf075f 100644 --- a/src/ceedlingProjectConfig.ts +++ b/src/ceedlingProjectConfig.ts @@ -46,6 +46,8 @@ export type ProjectState = { reportPluginEnabled: boolean, // undefined when the project doesn't enable Ceedling's gcov plugin. coverage: CoverageState | undefined, + // `:paths` ↳ `:test` as configured, e.g. `+:test/**`, which places each test's build files. + testPaths: string[], }; // What a coverage run leaves to read. Ceedling's gcov plugin writes a Cobertura report through @@ -78,6 +80,9 @@ export type CeedlingCapabilities = { pathQualifiedCoverageTasks: boolean, // ceedling.yml is a default project file, like project.yml. ceedlingYml: boolean, + // A test's build files nest under its file's directory within its test path, e.g. + // `test/out/unit/test_foo/` for `test/unit/test_foo.c`. See testBuildName(). + nestedTestBuilds: boolean, }; // The features of a reported Ceedling version. A build suffix such as `1.2.0-13a540f` counts as @@ -91,6 +96,7 @@ export function capabilitiesOf(version: string): CeedlingCapabilities { pathQualifiedTestTasks: atLeast('1.2.0'), pathQualifiedCoverageTasks: atLeast('1.2.1'), ceedlingYml: atLeast('1.2.0'), + nestedTestBuilds: atLeast('1.2.0'), }; } @@ -194,6 +200,7 @@ export function projectState(resolved: unknown, platform: NodeJS.Platform): Proj useParamTests: setting(resolved, ':unity', ':use_param_tests') === true, reportPluginEnabled: Array.isArray(enabledPlugins) && enabledPlugins.includes('report_tests_log_factory'), coverage: coverageState(resolved), + testPaths: stringList(setting(resolved, ':paths', ':test')), }; } @@ -205,7 +212,7 @@ export function coverageState(resolved: unknown): CoverageState | undefined { if (!Array.isArray(enabledPlugins) || !enabledPlugins.includes('gcov')) { return undefined; } - const names = (value: unknown) => Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string').map((v) => v.toLowerCase()) : []; + const names = (value: unknown) => stringList(value).map((name) => name.toLowerCase()); const listed = names(setting(resolved, ':gcov', ':utilities')); const utilities = listed.length > 0 ? listed : ['gcovr']; const cobertura = names(setting(resolved, ':gcov', ':reports')).includes('cobertura'); @@ -236,7 +243,37 @@ function setting(config: unknown, ...keys: string[]): unknown { return value; } +// The strings of a list setting. Anything else in the list, or a setting that isn't one, is ignored. +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; +} + function stringSetting(config: unknown, fallback: string, ...keys: string[]): string { const value = setting(config, ...keys); return typeof value === 'string' ? value : fallback; } + +// The name Ceedling gives a test's build directories, e.g. under test/out and gcov/out. Before +// 1.2.0 it is the test file's base name. From 1.2.0 the file's directory within the longest test +// path holding it comes first, as Ceedling's PathMirror.relative_subdir places it: `unit/test_foo` +// for `test/unit/test_foo.c` under `+:test/**`. Ceedling compares paths as written, with `/`. +export function testBuildName(testId: string, testPaths: readonly string[], nested: boolean): string { + const id = testId.replace(/\\/g, '/'); + const name = id.replace(/^.*\//, '').replace(/\.[^.]*$/, ''); + const directory = id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : '.'; + const roots = testPaths.map((testPath) => testPathRoot(testPath.replace(/\\/g, '/'))).filter((root) => root !== ''); + const [root] = roots.filter((r) => directory === r || directory.startsWith(`${r}/`)).sort((a, b) => b.length - a.length); + return nested && root !== undefined && directory !== root ? `${directory.slice(root.length + 1)}/${name}` : name; +} + +// A test path without its `+:` or `-:` decorator, cut back to the directory before its first glob, +// as Ceedling's FilePathUtils.no_decorators does. Empty when a glob leads it. +function testPathRoot(testPath: string): string { + const bare = testPath.trim().replace(/^[+-]:/, '').trim(); + const glob = bare.search(/[*?{}]/); + if (glob < 0) { + return bare.replace(/\/$/, ''); + } + const separator = bare.lastIndexOf('/', glob - 1); + return separator < 0 ? '' : separator === 0 ? '/' : bare.slice(0, separator); +} diff --git a/tests/unit/ceedlingEngine.test.ts b/tests/unit/ceedlingEngine.test.ts index be66ce3..0f08088 100644 --- a/tests/unit/ceedlingEngine.test.ts +++ b/tests/unit/ceedlingEngine.test.ts @@ -489,6 +489,7 @@ suite('CeedlingEngine with Ceedling 1.2.0', () => { // 1.2.0 nests each executable under its test file's directory, relative to the test path. test("a nested test file's executable is found under its directory", async () => { const world = new FakeCeedling(); + world.resolvedConfig = `:paths:\n :test:\n - "+:test/**"\n${world.resolvedConfig}`; world.testFiles = ['test/unit/test_calc.c']; world.write(`${WS}/test/unit/test_calc.c`, 'void test_add(void) {}\n'); const { engine, file } = await discovered(world); @@ -501,6 +502,35 @@ suite('CeedlingEngine with Ceedling 1.2.0', () => { assert.strictEqual(await engine.compileForDebug('default', file, recordingReporter(), token()), 'unit/test_calc/test_calc.out'); assert.deepStrictEqual(world.commandsMatching(/^ceedling test:/), [`ceedling test:test/unit/test_calc.c --force-test-rerun ${PROJECT_ARGS}`]); }); + + // The longest test path holding a file decides its build directory, not which build is newest. + test("a test file's executable follows its own test path, even beside a newer same-named one", async () => { + const world = new FakeCeedling(); + world.resolvedConfig = `:paths:\n :test:\n - "+:test/unit/**"\n - "+:extra/**"\n${world.resolvedConfig}`; + world.testFiles = ['test/unit/test_calc.c', 'extra/unit/test_calc.c']; + for (const f of world.testFiles) world.write(`${WS}/${f}`, 'void test_add(void) {}\n'); + const { engine, projects } = await discovered(world); + world.handlers.push((command) => /^ceedling test:/.test(command) ? { + writes: { + ...world.reportWrite(cppunit({ passed: ['test/unit/test_calc.c::test_add'] })), + [`${WS}/build/test/out/test_calc/test_calc.out`]: 'ELF', + [`${WS}/build/test/out/unit/test_calc/test_calc.out`]: 'ELF, written last', + }, + } : undefined); + const file = projects[0].files.find((f) => f.id === 'test/unit/test_calc.c')!; + assert.strictEqual(await engine.compileForDebug('default', file, recordingReporter(), token()), 'test_calc/test_calc.out'); + }); + + test("before Ceedling 1.2.0, a nested test file's executable builds under its bare name", async () => { + const world = new FakeCeedling(); + world.version = '1.1.10'; + world.resolvedConfig = `:paths:\n :test:\n - "+:test/**"\n${world.resolvedConfig}`; + world.testFiles = ['test/unit/test_calc.c']; + world.write(`${WS}/test/unit/test_calc.c`, 'void test_add(void) {}\n'); + const { engine, file } = await discovered(world); + world.handlers.push((command) => /^ceedling test:/.test(command) ? { writes: world.reportWrite(cppunit({ passed: ['test/unit/test_calc.c::test_add'] })) } : undefined); + assert.strictEqual(await engine.compileForDebug('default', file, recordingReporter(), token()), 'test_calc/test_calc.out'); + }); }); suite('CeedlingEngine runs', () => { @@ -1233,9 +1263,10 @@ suite('CeedlingEngine concurrent discovery', () => { }); // A resolved configuration enabling the gcov plugin, with its defaults as Ceedling merges them. -function gcovConfig(options: { utilities?: string[], reports?: string[], reportTask?: boolean } = {}): string { +function gcovConfig(options: { utilities?: string[], reports?: string[], reportTask?: boolean, testPaths?: string[] } = {}): string { const list = (items: string[]) => items.length === 0 ? ' []\n' : `\n${items.map((item) => ` - ${item}\n`).join('')}`; return ':plugins:\n :enabled:\n - report_tests_log_factory\n - gcov\n:unity:\n :use_param_tests: true\n' + + `:paths:\n :test:${list((options.testPaths ?? ['+:test/**']).map((p) => `"${p}"`))}` + `:gcov:\n :report_task: ${options.reportTask === true}\n :utilities:${list(options.utilities ?? ['gcovr'])} :reports:${list(options.reports ?? ['Cobertura'])}`; } @@ -1396,6 +1427,44 @@ suite('CeedlingEngine coverage', () => { assert.deepStrictEqual(Object.entries(counters).filter(([, f]) => world.files.has(f)).map(([k]) => k), ['root', 'unit', 'unitNested']); }); + test("a function run's whole-file fallback clears the counters its own test path places, not a namesake's", async () => { + const world = coverageWorld(undefined, gcovConfig({ testPaths: ['+:test/unit/**', '+:extra/**'] })); + world.version = '1.2.1'; + world.testFiles = ['test/unit/test_calc.c', 'extra/unit/test_calc.c']; + for (const f of world.testFiles) world.write(`${WS}/${f}`, 'void test_add(void) {}\n'); + const own = `${WS}/build/gcov/out/test_calc/calc.gcda`; + const namesake = `${WS}/build/gcov/out/unit/test_calc/calc.gcda`; + world.handlers.unshift((command) => /--test-case/.test(command) + ? { writes: world.reportWrite(cppunit({ passed: ['test/unit/test_calc.c::test_add', 'test/unit/test_calc.c::test_add_more'] }), 'build', 'gcov') } + : undefined); + const { engine, projects } = await discovered(world); + const file = projects[0].files.find((f) => f.id === 'test/unit/test_calc.c')!; + const mode = coverageMode(); + // A first build spends the request's one counter reset, which would clear the counters below. + await engine.runFile('default', file, recordingReporter(), token(), mode); + world.write(own, ''); + world.write(namesake, ''); + await engine.runFunction('default', file, file.functions[0], recordingReporter(), token(), mode); + assert.deepStrictEqual([own, namesake].map((f) => world.files.has(f)), [false, true]); + }); + + test("before Ceedling 1.2.0, a function run's whole-file fallback clears the counters under the test's bare name", async () => { + const world = coverageWorld(); + world.version = '1.1.10'; + world.testFiles = ['test/unit/test_calc.c']; + world.write(`${WS}/test/unit/test_calc.c`, 'void test_add(void) {}\n'); + const counter = `${WS}/build/gcov/out/test_calc/calc.gcda`; + world.handlers.unshift((command) => /--test-case/.test(command) + ? { writes: world.reportWrite(cppunit({ passed: ['test/unit/test_calc.c::test_add', 'test/unit/test_calc.c::test_add_more'] }), 'build', 'gcov') } + : undefined); + const { engine, file } = await discovered(world); + const mode = coverageMode(); + await engine.runFile('default', file, recordingReporter(), token(), mode); + world.write(counter, ''); + await engine.runFunction('default', file, file.functions[0], recordingReporter(), token(), mode); + assert.ok(!world.files.has(counter)); + }); + // Windows keeps the case a directory was created with, which may differ from the file's path. test("on Windows, a function run's whole-file fallback finds its counters whatever their directories' case", async () => { const world = new FakeCeedling(); diff --git a/tests/unit/ceedlingProjectConfig.test.ts b/tests/unit/ceedlingProjectConfig.test.ts index 3072207..e1c5aae 100644 --- a/tests/unit/ceedlingProjectConfig.test.ts +++ b/tests/unit/ceedlingProjectConfig.test.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import * as fs from 'fs'; import yaml from 'js-yaml'; import * as path from 'path'; -import { capabilitiesOf, CeedlingLoadError, coverageProblem, coverageState, pathKey, projectState, projectsFromSettings } from '../../src/ceedlingProjectConfig'; +import { capabilitiesOf, CeedlingLoadError, coverageProblem, coverageState, pathKey, projectState, projectsFromSettings, testBuildName } from '../../src/ceedlingProjectConfig'; const fixturesRoot = path.resolve(__dirname, '..', '..', '..', 'tests', 'fixtures'); @@ -131,7 +131,7 @@ suite('projectsFromSettings: the default project file', () => { }); suite('capabilitiesOf', () => { - const none = { dumpconfigStdout: false, forceTestRerun: false, pathQualifiedTestTasks: false, pathQualifiedCoverageTasks: false, ceedlingYml: false }; + const none = { dumpconfigStdout: false, forceTestRerun: false, pathQualifiedTestTasks: false, pathQualifiedCoverageTasks: false, ceedlingYml: false, nestedTestBuilds: false }; test('Ceedling before 1.2.0 has none of the 1.2.0 features', () => { for (const version of ['1.0.0', '1.1.10', '1.1.10-85ec551']) { @@ -143,13 +143,13 @@ suite('capabilitiesOf', () => { // 1.2.0's gcov: tasks still take only a bare file name. test('Ceedling 1.2.0, build suffix included, has every 1.2.0 feature but path-qualified coverage tasks', () => { for (const version of ['1.2.0', '1.2.0-13a540f']) { - assert.deepStrictEqual(capabilitiesOf(version), { ...none, dumpconfigStdout: true, forceTestRerun: true, pathQualifiedTestTasks: true, ceedlingYml: true }); + assert.deepStrictEqual(capabilitiesOf(version), { ...none, dumpconfigStdout: true, forceTestRerun: true, pathQualifiedTestTasks: true, ceedlingYml: true, nestedTestBuilds: true }); } }); test('Ceedling 1.2.1 and later, build suffix included, also takes paths in coverage tasks', () => { for (const version of ['1.2.1', '1.2.1-0a1b2c3', '1.3.1']) { - assert.deepStrictEqual(capabilitiesOf(version), { dumpconfigStdout: true, forceTestRerun: true, pathQualifiedTestTasks: true, pathQualifiedCoverageTasks: true, ceedlingYml: true }); + assert.deepStrictEqual(capabilitiesOf(version), { dumpconfigStdout: true, forceTestRerun: true, pathQualifiedTestTasks: true, pathQualifiedCoverageTasks: true, ceedlingYml: true, nestedTestBuilds: true }); } }); }); @@ -166,10 +166,16 @@ suite('projectState', () => { useParamTests: false, reportPluginEnabled: false, coverage: undefined, + testPaths: [], }); } }); + test('the test paths are read as configured, decorators and globs included', () => { + assert.deepStrictEqual(projectState({ ':paths': { ':test': ['+:test/**', '-:test/support', 7] } }, 'linux').testPaths, ['+:test/**', '-:test/support']); + assert.deepStrictEqual(projectState(dumpconfigFixture('dumpconfig-gcov-default.yml', 'ceedling-1.2.0'), 'linux').testPaths, ['+:test/**']); + }); + test('the default executable extension is .exe on Windows', () => { assert.strictEqual(projectState({}, 'win32').executableExtension, '.exe'); }); @@ -182,6 +188,7 @@ suite('projectState', () => { ':unity': { ':use_param_tests': true }, ':plugins': { ':enabled': ['report_tests_log_factory'] }, ':report_tests_log_factory': { ':cppunit': { ':filename': 'mine.xml' } }, + ':paths': { ':test': ['spec/**'] }, }, 'linux'); assert.deepStrictEqual(state, { buildRoot: 'out', @@ -192,6 +199,7 @@ suite('projectState', () => { useParamTests: true, reportPluginEnabled: true, coverage: undefined, + testPaths: ['spec/**'], }); }); @@ -231,6 +239,52 @@ suite('projectState', () => { }); // Two spellings of one Windows directory must share a lock, or their builds could overlap. +// Ceedling names a test's build for its file's directory within the longest test path holding it: +// PathMirror.relative_subdir in 1.2.0 and 1.2.1, over :paths: :test: stripped of decorators and globs. +suite('testBuildName', () => { + const nested = (id: string, paths: string[]) => testBuildName(id, paths, true); + + test('before Ceedling 1.2.0, a test builds under its bare name', () => { + assert.strictEqual(testBuildName('test/unit/test_calc.c', ['+:test/**'], false), 'test_calc'); + }); + + test("from 1.2.0, a test builds under its directory within its test path, or its bare name at that path's root", () => { + assert.strictEqual(nested('test/unit/test_calc.c', ['+:test/**']), 'unit/test_calc'); + assert.strictEqual(nested('test/test_calc.c', ['+:test/**']), 'test_calc'); + assert.strictEqual(nested('test/a/b/test_calc.cpp', ['test/**']), 'a/b/test_calc'); + }); + + test('the longest test path holding the file decides, so the same files may build in either layout', () => { + assert.strictEqual(nested('test/unit/test_calc.c', ['+:test/**', '+:test/unit/**']), 'test_calc'); + const files = ['test/unit/test_calc.c', 'extra/unit/test_calc.c']; + assert.deepStrictEqual(files.map((f) => nested(f, ['+:test/unit/**', '+:extra/**'])), ['test_calc', 'unit/test_calc']); + assert.deepStrictEqual(files.map((f) => nested(f, ['+:test/**', '+:extra/unit/**'])), ['unit/test_calc', 'test_calc']); + }); + + test('a test path is cut back to the directory before its first glob, and a subtractive one counts as well', () => { + assert.strictEqual(nested('test/x/unit/test_calc.c', ['test/*/unit']), 'x/unit/test_calc'); + assert.strictEqual(nested('test/unit/test_calc.c', ['test/unit/']), 'test_calc'); + assert.strictEqual(nested('test/unit/test_calc.c', [' +: test ']), 'unit/test_calc'); + assert.strictEqual(nested('test/support/x/test_calc.c', ['+:test/**', '-:test/support']), 'x/test_calc'); + }); + + test('a file under no usable test path builds under its bare name', () => { + assert.strictEqual(nested('other/unit/test_calc.c', ['+:test/**']), 'test_calc'); + assert.strictEqual(nested('test/unit/test_calc.c', ['**/unit']), 'test_calc'); + assert.strictEqual(nested('test_calc.c', ['+:test/**']), 'test_calc'); + }); + + // Ceedling cuts `/*` to the root directory `/`, which then holds nothing, as `//` starts no path. + test('absolute test paths place absolute test files, and the filesystem root holds none', () => { + assert.strictEqual(nested('/abs/test/unit/test_calc.c', ['+:/abs/test/**']), 'unit/test_calc'); + assert.strictEqual(nested('/unit/test_calc.c', ['/*']), 'test_calc'); + }); + + test('backslashes in paths and ids read as separators, as Ceedling standardizes them', () => { + assert.strictEqual(nested('test\\unit\\test_calc.c', ['+:test\\**']), 'unit/test_calc'); + }); +}); + suite('pathKey', () => { test('on Windows, spellings of one path differing only in case share a key', () => { assert.strictEqual(pathKey('C:\\Work\\FW', 'win32'), pathKey('c:\\work\\fw', 'win32'));