diff --git a/packages/create-objectstack/src/runtime-image.test.ts b/packages/create-objectstack/src/runtime-image.test.ts index 7522579f348..776a350c439 100644 --- a/packages/create-objectstack/src/runtime-image.test.ts +++ b/packages/create-objectstack/src/runtime-image.test.ts @@ -95,7 +95,14 @@ describe('scaffolded Dockerfile runtime image tag (#9017)', () => { // The version npm resolved is deliberately NOT the range's floor: that is // the normal case (`^17.0.0` installs the newest 17.x) and it is the case // that would go unnoticed if the tag were taken from package.json instead. - const resolved = `${String(ownVersion).split('.')[0]}.4.2`; + // + // It is DERIVED from the scaffolder's own version — same major, next minor + // — never spelled as a literal. The scaffolder writes `^`, so a + // literal `.4.2` lies inside that range only while ownVersion's minor + // is at most 4; the version pass to 17.5.0 put it BELOW the floor, and the + // agreement test went red on a fixture defect while blaming the scaffolder. + const [ownMajor, ownMinor] = String(ownVersion).split('.').map(Number); + const resolved = `${ownMajor}.${ownMinor + 1}.2`; beforeEach(() => { scaffold(dir); @@ -104,6 +111,19 @@ describe('scaffolded Dockerfile runtime image tag (#9017)', () => { expect(result.pinned, 'pinRuntimeImage refused the scaffolded Dockerfile').toBe(true); }); + // A precondition on the fixture, not on the scaffolder: should the planted + // version ever leave the range (or land on its floor), this names the + // fixture as the fault instead of the agreement test below reporting a + // runtime-image mismatch the scaffolder never produced. + it('plants a resolved version inside the scaffolded range and above its floor', () => { + expect(resolved).not.toBe(ownVersion); + expect( + satisfiesCaret(`^${ownVersion}`, resolved), + `fixture defect: the planted @objectstack/cli ${resolved} does not ` + + `satisfy ^${ownVersion}, the range the scaffolder writes for its own version`, + ).toBe(true); + }); + it('pins the FROM tag to the @objectstack/cli the project resolved', () => { expect(runtimeTag(dir)).toBe(resolved); }); diff --git a/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts b/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts index be1ca4f222b..7dec75cc875 100644 --- a/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts +++ b/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts @@ -30,6 +30,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineStack } from '@objectstack/spec'; +import { resolveInstalledSpecVersion } from '@objectstack/metadata-core'; import { MetadataPlugin } from './plugin.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -58,6 +59,14 @@ function newPlugin(): any { return new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }); } +/** Order two versions by their leading `x.y.z` (a prerelease tail is ignored). */ +function compareVersions(a: string, b: string): number { + const triple = (v: string) => v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10)); + const [x, y] = [triple(a), triple(b)]; + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! < y[i]! ? -1 : 1; + return 0; +} + /** The conversion summary lines the door logged, keyed by conversion id. */ function conversionWarns(ctx: any): Map { const byId = new Map(); @@ -111,10 +120,31 @@ describe('[#20390] artifact door — a 17.4.0-built artifact boots on unreleased expect(warns.get('dashboard-widget-chart-config-structure-removed')![0]).toContain('3 site(s)'); expect(warns.get('page-assigned-profiles-removed')).toHaveLength(1); expect(warns.get('page-assigned-profiles-removed')![0]).toContain('1 site(s)'); - // Under the per-entry half the floor is not below the runtime's label, - // so the line must not claim the artifact "predates" a runtime printed - // at the same version — it names the retirement that opened it instead. - for (const line of [...warns.values()].flat()) expect(line).not.toContain("predates this runtime's spec"); + // WHICH half of the window opened is decided by the running spec's + // label, and the version pass moves that label — so the clause is + // pinned per half, never as one literal. Under the per-entry half + // (unreleased `main`, label still 17.4.0) the floor is not below the + // label, so the line must not claim the artifact "predates" a runtime + // printed at the same version — it names the retirement that opened it + // instead. Once the label is past the floor (the 17.5.0 version pass + // onward) the label half opened it, and "predates" is exactly the + // claim the line owes, beside a runtime version that bears it out. + const floor = String(loadFixture().manifest.engines.protocol).replace(/^\^/, ''); + const installed = resolveInstalledSpecVersion(); + expect(installed).toMatch(/^\d+\.\d+\.\d+/); // spec is always resolvable here + const labelPastFloor = compareVersions(installed!, floor) > 0; + const lines = [...warns.values()].flat(); + expect(lines).toHaveLength(2); + for (const line of lines) { + if (labelPastFloor) { + expect(line).toContain( + `predates this runtime's spec (authored engines.protocol floor ${floor}, runtime spec ${installed})`, + ); + } else { + expect(line).not.toContain("predates this runtime's spec"); + expect(line).toContain('was built on a surface that still accepted shapes this runtime has since retired'); + } + } }); /**