* test(#3337): fold the manifest & package-identity issue-* cluster — Wave 5 Folds 6 legacy issue-*.test.cjs regression files (120 test() blocks) into their module's main suite, per H3 (#3315) of the test-hygiene epic (#3053). Second of 4 issue-* waves. - issue-766-plugin-manifest.test.cjs (50 tests): pure rename (git mv) into plugin-manifest.test.cjs, sole comprehensive suite for its module. - issue-844-manifest-version-sync.test.cjs (14 tests, rename basis) + issue-1855-marketplace-manifest.test.cjs (17 tests, merged in): both target scripts/sync-manifest-versions.cjs via non-overlapping describe blocks (generic engine vs. marketplace.json-specific), now manifest-version-sync.test.cjs, 31 tests, 0 dropped. - issue-498-identity-drift-lint.test.cjs (8 tests, rename basis) + issue-498-package-identity.test.cjs (17 tests, merged in): both target package-identity.cjs's surface from different angles (lint-side drift detection vs. derive/slugify), now package-identity.test.cjs, 25 tests, 0 dropped. - issue-607-cache-lineage.test.cjs (14 tests) merged into the existing gsd-statusline.test.cjs, matching its already-established fold-wrapper convention from prior waves. Learned from Wave 4: fold agents checked src/** (not just docs/ and gsd-core/references/) for stale filename references — found and fixed 5 across 3 ADR docs (766, 2121, 457), zero in src/ this time. Zero net test-coverage loss. No production code changed. * test(#3337): fix orthogonal-review findings — Wave 5 fold Standards-axis review found real issues in the just-folded manifest-version-sync.test.cjs, both fixed here: - The folded:issue-1855-marketplace-manifest block omitted its own test/describe/assert/fs/path/os requires, silently closing over the #844 basis section's outer-scope bindings instead of declaring its own dependencies — inconsistent with every other fold block in this wave. Restored the local requires the original issue-1855 file declared. - Merging two independently-lettered legacy files (each A-F) left duplicate top-level describe() labels (two "A:", two "B:", two "C:"). Renamed the folded-in #1855 labels (A2/B4/C2) to make every top-level label in the file unique. No test() count changed (31). No production code touched. --------- Co-authored-by: sim <sim@local>
596 lines
27 KiB
JavaScript
596 lines
27 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Regression tests for issue #844: manifest version sync.
|
|
*
|
|
* Verifies that `scripts/sync-manifest-versions.cjs` correctly stamps the
|
|
* package.json version into every registered runtime-integration manifest,
|
|
* and that all currently-tracked manifests are in sync.
|
|
*
|
|
* Key deliverable: the regression guard (test d) asserts that any committed
|
|
* JSON file with a top-level `version` field matching package.json is
|
|
* registered in VERSIONED_MANIFESTS — forcing explicit opt-in for future
|
|
* manifests.
|
|
*/
|
|
|
|
const { test, describe, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const helpers = require(path.join(__dirname, 'helpers.cjs'));
|
|
const {
|
|
VERSIONED_MANIFESTS,
|
|
VERSIONED_MANIFEST_PATHS,
|
|
getByPath,
|
|
setByPath,
|
|
syncManifestVersions,
|
|
getPackageVersion,
|
|
stageManifests,
|
|
listCapabilityManifests,
|
|
syncCapabilityVersions,
|
|
} = require(path.join(ROOT, 'scripts', 'sync-manifest-versions.cjs'));
|
|
|
|
// ─── A: RED→GREEN repro via temp fixture ─────────────────────────────────────
|
|
//
|
|
// Each test in this describe operates on a single per-describe tmpRoot that is
|
|
// created in before() and torn down in after(). There are no setup/cleanup
|
|
// test() nodes — order-independence is guaranteed by the lifecycle hooks.
|
|
describe('A: syncManifestVersions — temp fixture', () => {
|
|
|
|
let tmpRoot;
|
|
|
|
before(() => {
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-844-'));
|
|
|
|
// Write tmp package.json
|
|
fs.writeFileSync(
|
|
path.join(tmpRoot, 'package.json'),
|
|
JSON.stringify({ name: 'x', version: '9.9.9-test.0' }, null, 2) + '\n'
|
|
);
|
|
|
|
// Copy real manifests into tmp, stamped at OLD version so tests can
|
|
// verify the pre-sync (stale) state and post-sync (updated) state.
|
|
for (const entry of VERSIONED_MANIFESTS) {
|
|
const realAbs = path.join(ROOT, entry.path);
|
|
const manifest = JSON.parse(fs.readFileSync(realAbs, 'utf8'));
|
|
setByPath(manifest, entry.versionKey, '0.0.0');
|
|
|
|
const destAbs = path.join(tmpRoot, entry.path);
|
|
const destDir = path.dirname(destAbs);
|
|
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
fs.writeFileSync(destAbs, JSON.stringify(manifest, null, 2) + '\n');
|
|
}
|
|
|
|
// Run the sync once so post-sync assertions are valid.
|
|
syncManifestVersions({ root: tmpRoot });
|
|
});
|
|
|
|
after(() => {
|
|
helpers.cleanup(tmpRoot);
|
|
tmpRoot = null;
|
|
});
|
|
|
|
test('pre-sync fixture had at least one stale manifest (version 0.0.0 != 9.9.9-test.0)', () => {
|
|
// The before() hook wrote 0.0.0 into every manifest before syncing.
|
|
// We verify the sync actually had work to do by checking that any manifest
|
|
// that now reads 9.9.9-test.0 was not already at that version (0.0.0 ≠ 9.9.9-test.0).
|
|
// The simplest red-check: the fixture started with 0.0.0, which != 9.9.9-test.0.
|
|
assert.ok(
|
|
VERSIONED_MANIFESTS.length > 0,
|
|
'VERSIONED_MANIFESTS must be non-empty for the fixture to be meaningful'
|
|
);
|
|
// Independently confirm the target version != the stale seed
|
|
assert.notEqual('0.0.0', '9.9.9-test.0',
|
|
'Stale seed 0.0.0 must differ from fixture package.json version 9.9.9-test.0');
|
|
});
|
|
|
|
test('syncManifestVersions stamps all manifests to package.json version', () => {
|
|
const pkgVersion = getPackageVersion(tmpRoot);
|
|
assert.equal(pkgVersion, '9.9.9-test.0');
|
|
|
|
for (const entry of VERSIONED_MANIFESTS) {
|
|
const abs = path.join(tmpRoot, entry.path);
|
|
const m = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
|
assert.equal(
|
|
getByPath(m, entry.versionKey),
|
|
'9.9.9-test.0',
|
|
`${entry.path} version (${entry.versionKey}) should be 9.9.9-test.0 after sync`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('syncManifestVersions reports at least one changed file on first run', () => {
|
|
// Run a fresh sync against a freshly-stale fixture to observe the changed list.
|
|
// Create a separate sub-fixture so this test does not rely on before()'s sync order.
|
|
const sub = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-844-chk-'));
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(sub, 'package.json'),
|
|
JSON.stringify({ name: 'x', version: '9.9.9-test.0' }, null, 2) + '\n'
|
|
);
|
|
for (const entry of VERSIONED_MANIFESTS) {
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, entry.path), 'utf8'));
|
|
setByPath(manifest, entry.versionKey, '0.0.0');
|
|
const destAbs = path.join(sub, entry.path);
|
|
const destDir = path.dirname(destAbs);
|
|
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
fs.writeFileSync(destAbs, JSON.stringify(manifest, null, 2) + '\n');
|
|
}
|
|
const changed = syncManifestVersions({ root: sub });
|
|
assert.ok(changed.length > 0, 'syncManifestVersions should report at least one changed file');
|
|
} finally {
|
|
helpers.cleanup(sub);
|
|
}
|
|
});
|
|
|
|
test('non-version fields are preserved after sync', () => {
|
|
for (const entry of VERSIONED_MANIFESTS) {
|
|
const rel = entry.path;
|
|
const real = JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'));
|
|
const tmp = JSON.parse(fs.readFileSync(path.join(tmpRoot, rel), 'utf8'));
|
|
// Check that every non-version key from the real manifest exists in tmp.
|
|
// For nested versionKey manifests (e.g. marketplace plugins[0].version),
|
|
// the comparison is structural at the top level only — the version slot
|
|
// itself is the one field sync is allowed to change.
|
|
for (const key of Object.keys(real)) {
|
|
assert.ok(
|
|
Object.prototype.hasOwnProperty.call(tmp, key),
|
|
`${rel}: field "${key}" should be preserved after sync`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('each synced file ends with a single trailing newline', () => {
|
|
for (const entry of VERSIONED_MANIFESTS) {
|
|
const rel = entry.path;
|
|
const raw = fs.readFileSync(path.join(tmpRoot, rel), 'utf8');
|
|
assert.ok(raw.endsWith('\n'), `${rel} must end with a trailing newline`);
|
|
assert.ok(!raw.endsWith('\n\n'), `${rel} must not end with a double newline`);
|
|
}
|
|
});
|
|
|
|
test('second syncManifestVersions call is idempotent (returns [])', () => {
|
|
// The before() already ran one sync. A second call must return [].
|
|
const changed = syncManifestVersions({ root: tmpRoot });
|
|
assert.deepEqual(changed, [], 'Second sync call should return [] (already in sync)');
|
|
});
|
|
});
|
|
|
|
// ─── B: Registry-in-sync: real manifests match package.json ──────────────────
|
|
describe('B: real manifests match package.json version', () => {
|
|
|
|
const pkgVersion = getPackageVersion(ROOT);
|
|
|
|
for (const entry of VERSIONED_MANIFESTS) {
|
|
const rel = entry.path;
|
|
test(`${rel} version (${entry.versionKey}) === ${pkgVersion}`, () => {
|
|
const abs = path.join(ROOT, rel);
|
|
assert.ok(fs.existsSync(abs), `${rel} must exist at ${abs}`);
|
|
const m = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
|
assert.equal(
|
|
getByPath(m, entry.versionKey),
|
|
pkgVersion,
|
|
`${rel} version (${entry.versionKey} = ${getByPath(m, entry.versionKey)}) must match package.json version (${pkgVersion}). ` +
|
|
'Run `node scripts/sync-manifest-versions.cjs` to fix.'
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── B2: native capability manifests track package.json version (ADR-1244 D6) ─
|
|
describe('B2: native capability manifests match package.json version', () => {
|
|
|
|
const pkgVersion = getPackageVersion(ROOT);
|
|
const capManifests = listCapabilityManifests({ root: ROOT });
|
|
|
|
test('there is at least one native capability manifest', () => {
|
|
assert.ok(capManifests.length >= 30, `expected the native capability set, found ${capManifests.length}`);
|
|
});
|
|
|
|
for (const rel of capManifests) {
|
|
test(`${rel} version === ${pkgVersion}`, () => {
|
|
const m = JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'));
|
|
assert.equal(
|
|
m.version,
|
|
pkgVersion,
|
|
`${rel} version (${m.version}) must match package.json version (${pkgVersion}). ` +
|
|
'Run `node scripts/sync-manifest-versions.cjs` to fix.'
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── B3: syncCapabilityVersions stamps + is idempotent (temp fixture) ─────────
|
|
describe('B3: syncCapabilityVersions — temp fixture', () => {
|
|
|
|
test('stamps stale capability manifests to package version, then is idempotent', () => {
|
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-844-cap-'));
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(tmpRoot, 'package.json'),
|
|
JSON.stringify({ name: 'x', version: '9.9.9-test.0' }, null, 2) + '\n'
|
|
);
|
|
// Two stale capability manifests.
|
|
for (const id of ['alpha', 'beta']) {
|
|
const dir = path.join(tmpRoot, 'capabilities', id);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'capability.json'),
|
|
JSON.stringify({ id, role: 'feature', version: '0.0.0', title: id }, null, 2) + '\n'
|
|
);
|
|
}
|
|
|
|
const found = listCapabilityManifests({ root: tmpRoot });
|
|
assert.equal(found.length, 2, 'should discover both capability manifests');
|
|
|
|
const changed = syncCapabilityVersions({ root: tmpRoot });
|
|
assert.equal(changed.length, 2, 'both manifests should be stamped on first run');
|
|
for (const rel of found) {
|
|
const m = JSON.parse(fs.readFileSync(path.join(tmpRoot, rel), 'utf8'));
|
|
assert.equal(m.version, '9.9.9-test.0', `${rel} should be stamped`);
|
|
assert.equal(m.title, m.id, `${rel} non-version fields preserved`);
|
|
}
|
|
|
|
// Idempotent second run.
|
|
assert.deepEqual(syncCapabilityVersions({ root: tmpRoot }), [], 'second run is a no-op');
|
|
} finally {
|
|
helpers.cleanup(tmpRoot);
|
|
}
|
|
});
|
|
|
|
test('listCapabilityManifests returns [] when there is no capabilities/ dir', () => {
|
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-844-nocaps-'));
|
|
try {
|
|
assert.deepEqual(listCapabilityManifests({ root: tmpRoot }), []);
|
|
} finally {
|
|
helpers.cleanup(tmpRoot);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── C: Regression guard — all version-bearing JSON files are registered ──────
|
|
describe('C: regression guard — version-bearing JSON files must be registered', () => {
|
|
|
|
// package.json is the version source; package-lock.json is npm-managed.
|
|
// Both inherently track the version without the sync script.
|
|
// Native capability manifests (capabilities/<id>/capability.json) are
|
|
// version-swept by syncCapabilityVersions (ADR-1244 D6) — discovered by glob,
|
|
// so every one is "registered" without an explicit entry here.
|
|
const ALLOWED = new Set([
|
|
...VERSIONED_MANIFEST_PATHS,
|
|
...listCapabilityManifests({ root: ROOT }),
|
|
'package.json',
|
|
'package-lock.json',
|
|
]);
|
|
|
|
// Semver-ish: matches X.Y.Z with optional pre-release/build metadata.
|
|
const SEMVER = /^\d+\.\d+\.\d+(?:[-+].+)?$/;
|
|
|
|
// Paths to exclude from the guard
|
|
const EXCLUDED_PREFIXES = ['tests/', 'node_modules/', '.changeset/', 'docs/'];
|
|
|
|
test('every committed JSON with a semver top-level version is registered or explicitly allowed', (t) => {
|
|
// Enumerate committed JSON files via git (no pathspec to avoid recursion quirks;
|
|
// filter to .json in JS instead).
|
|
let lines;
|
|
try {
|
|
// `gitOrThrow` returns a string (the seam is always utf-8), so no
|
|
// `.toString()` is needed here — the original Buffer#toString() call
|
|
// this replaces was a no-op on the seam's already-string stdout.
|
|
const out = gitOrThrow(['ls-files'], { cwd: ROOT });
|
|
lines = out.split('\n').filter((f) => f.endsWith('.json'));
|
|
} catch (err) {
|
|
t.skip('git unavailable: ' + err.message);
|
|
return;
|
|
}
|
|
|
|
for (const rel of lines) {
|
|
if (ALLOWED.has(rel)) continue;
|
|
if (EXCLUDED_PREFIXES.some(prefix => rel.startsWith(prefix))) continue;
|
|
|
|
const abs = path.join(ROOT, rel);
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
|
} catch (_) {
|
|
continue; // skip invalid JSON (shouldn't exist, but be safe)
|
|
}
|
|
|
|
if (
|
|
parsed &&
|
|
typeof parsed === 'object' &&
|
|
!Array.isArray(parsed) &&
|
|
typeof parsed.version === 'string' &&
|
|
SEMVER.test(parsed.version)
|
|
) {
|
|
assert.ok(
|
|
ALLOWED.has(rel),
|
|
`${rel} has a semver top-level "version" but is not registered in ` +
|
|
'scripts/sync-manifest-versions.cjs VERSIONED_MANIFESTS (nor an npm-managed file). ' +
|
|
"Register it so 'npm version' keeps it in sync (issue #844)."
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── E: stageManifests in a non-git dir must not throw ───────────────────────
|
|
describe('E: stageManifests — non-git dir is a no-op, not a throw', () => {
|
|
|
|
test('stageManifests({root}) with a non-git tempdir warns and returns without throwing', () => {
|
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-844-nogit-'));
|
|
try {
|
|
// Write a minimal package.json so getPackageVersion doesn't error if called
|
|
fs.writeFileSync(
|
|
path.join(tmpRoot, 'package.json'),
|
|
JSON.stringify({ name: 'x', version: '0.0.0' }, null, 2) + '\n'
|
|
);
|
|
// Must not throw even though tmpRoot is not a git repo
|
|
assert.doesNotThrow(() => {
|
|
stageManifests({ root: tmpRoot });
|
|
}, 'stageManifests must not throw outside a git work tree');
|
|
} finally {
|
|
helpers.cleanup(tmpRoot);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── D: CLI --check exits 0 when in sync ─────────────────────────────────────
|
|
// Moved to `npm run lint:generated-sync` (sync-manifest-versions.cjs --check),
|
|
// which runs against the committed manifests in both local and CI lint lanes
|
|
// instead of being masked by gsd-test's `npm run build` leg.
|
|
|
|
// ─── F: version script includes capability-registry regen (#1498) ─────────────
|
|
//
|
|
// Regression guard for #1498: the `npm version` lifecycle script must regenerate
|
|
// capability-registry.cjs after stamping capability manifests. Without this,
|
|
// `npm version X.Y.Z` leaves the committed registry stale (capability JSONs get
|
|
// new version strings but the registry still has the old ones), causing the
|
|
// `gen-capability-registry.cjs --check` test to fail in the RC workflow.
|
|
describe('F: npm version script includes gen-capability-registry --write (#1498)', () => {
|
|
|
|
test('package.json "version" script regenerates capability-registry.cjs after syncing manifests', () => {
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
const versionScript = pkg.scripts && pkg.scripts.version;
|
|
assert.ok(
|
|
typeof versionScript === 'string',
|
|
'package.json must have a "version" script',
|
|
);
|
|
assert.ok(
|
|
versionScript.includes('gen-capability-registry.cjs --write'),
|
|
'package.json "version" script must include "gen-capability-registry.cjs --write" to keep the registry in sync after npm version bumps capability manifests. ' +
|
|
'Got: ' + JSON.stringify(versionScript),
|
|
);
|
|
assert.ok(
|
|
versionScript.includes('git add') && versionScript.includes('capability-registry.cjs'),
|
|
'package.json "version" script must stage capability-registry.cjs with "git add" so it is included in the version-bump commit. ' +
|
|
'Got: ' + JSON.stringify(versionScript),
|
|
);
|
|
});
|
|
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/issue-1855-marketplace-manifest.test.cjs — H3 wave 5 (#3337)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe('folded:issue-1855-marketplace-manifest', () => {
|
|
// Regression tests for issue #1855: Claude plugin marketplace manifest.
|
|
//
|
|
// Asserts structural and semantic correctness of .claude-plugin/marketplace.json
|
|
// — the marketplace-discovery sibling of .claude-plugin/plugin.json (#766). The
|
|
// version that runtimes read lives at plugins[0].version (the canonical
|
|
// marketplace schema location), kept in sync with package.json by
|
|
// scripts/sync-manifest-versions.cjs via a nested versionKey descriptor.
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const pkg = require(path.join(ROOT, 'package.json'));
|
|
const pluginJson = require(path.join(ROOT, '.claude-plugin', 'plugin.json'));
|
|
const MARKETPLACE_JSON_PATH = path.join(ROOT, '.claude-plugin', 'marketplace.json');
|
|
const MARKETPLACE_REL = '.claude-plugin/marketplace.json';
|
|
|
|
// ─── Section A2: marketplace.json structure ──────────────────────────────────
|
|
describe('A2: .claude-plugin/marketplace.json', () => {
|
|
|
|
let manifest;
|
|
|
|
test('exists and is valid JSON', () => {
|
|
assert.ok(fs.existsSync(MARKETPLACE_JSON_PATH), '.claude-plugin/marketplace.json must exist');
|
|
const raw = fs.readFileSync(MARKETPLACE_JSON_PATH, 'utf-8');
|
|
manifest = JSON.parse(raw); // throws on invalid JSON
|
|
assert.ok(typeof manifest === 'object' && manifest !== null, 'manifest must be a JSON object');
|
|
});
|
|
|
|
test('top-level name is a non-empty string', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.ok(typeof manifest.name === 'string' && manifest.name.trim().length > 0, 'marketplace name must be a non-empty string');
|
|
});
|
|
|
|
test('top-level description is a non-empty string', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.ok(typeof manifest.description === 'string' && manifest.description.trim().length > 0, 'marketplace description must be a non-empty string');
|
|
});
|
|
|
|
test('owner.{name,url} are non-empty strings', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.ok(manifest.owner && typeof manifest.owner.name === 'string' && manifest.owner.name.trim().length > 0, 'owner.name must be a non-empty string');
|
|
assert.ok(typeof manifest.owner.url === 'string' && /^https?:\/\//.test(manifest.owner.url), 'owner.url must be an http(s) URL');
|
|
});
|
|
|
|
test('plugins[] is a non-empty array', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.ok(Array.isArray(manifest.plugins) && manifest.plugins.length > 0, 'plugins must be a non-empty array');
|
|
});
|
|
|
|
test('plugins[0] has the gsd-core entry with source "./"', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
const entry = manifest.plugins[0];
|
|
assert.ok(entry && typeof entry === 'object', 'plugins[0] must be an object');
|
|
assert.equal(entry.name, pluginJson.name, `plugins[0].name (${entry && entry.name}) must equal plugin.json name (${pluginJson.name})`);
|
|
assert.equal(entry.source, './', 'plugins[0].source must be "./" (repo root, same as plugin.json relative refs)');
|
|
assert.ok(typeof entry.description === 'string' && entry.description.trim().length > 0, 'plugins[0].description must be a non-empty string');
|
|
});
|
|
|
|
test('plugins[0].author.{name,url} match plugin.json author / owner', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
const entry = manifest.plugins[0];
|
|
assert.ok(entry.author && typeof entry.author.name === 'string' && entry.author.name.trim().length > 0, 'plugins[0].author.name must be a non-empty string');
|
|
assert.equal(entry.author.name, pluginJson.author && pluginJson.author.name, 'plugins[0].author.name must match plugin.json author.name');
|
|
});
|
|
|
|
test('plugins[0].version matches package.json version (synced)', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
const entry = manifest.plugins[0];
|
|
assert.equal(
|
|
entry.version,
|
|
pkg.version,
|
|
`plugins[0].version (${entry.version}) must match package.json version (${pkg.version}). ` +
|
|
'Run `node scripts/sync-manifest-versions.cjs` to fix — the marketplace plugin version is stamped via a nested versionKey descriptor. (#1855)'
|
|
);
|
|
});
|
|
|
|
test('no plugins[0].$schema key (intentionally omitted, parity with plugin.json)', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
const entry = manifest.plugins[0];
|
|
assert.ok(!Object.prototype.hasOwnProperty.call(entry, '$schema'), 'plugins[0] must NOT contain a $schema key');
|
|
});
|
|
});
|
|
|
|
// ─── Section B4: registration in the version-sync registry ───────────────────
|
|
describe('B4: marketplace.json is registered for version sync', () => {
|
|
|
|
test('marketplace.json path appears in VERSIONED_MANIFESTS', () => {
|
|
const paths = VERSIONED_MANIFESTS.map((e) => (typeof e === 'string' ? e : e && e.path));
|
|
assert.ok(
|
|
paths.includes(MARKETPLACE_REL),
|
|
`VERSIONED_MANIFESTS must register ${MARKETPLACE_REL} so 'npm version' keeps plugins[0].version in sync (issue #844 / #1855). Got: ${JSON.stringify(paths)}`
|
|
);
|
|
});
|
|
|
|
test('marketplace.json entry uses the nested plugins.0.version key', () => {
|
|
const entry = VERSIONED_MANIFESTS.find((e) => (typeof e === 'string' ? e : e && e.path) === MARKETPLACE_REL);
|
|
// Nested dot-path is what makes the canonical marketplace version (plugins[0].version) the stamped field.
|
|
const versionKey = typeof entry === 'string' ? 'version' : entry && entry.versionKey;
|
|
assert.equal(
|
|
versionKey,
|
|
'plugins.0.version',
|
|
`${MARKETPLACE_REL} must be registered with versionKey 'plugins.0.version' (the schema-canonical location runtimes read). Got: ${JSON.stringify(entry)}`
|
|
);
|
|
});
|
|
|
|
test('marketplace.json is in the staging list (stageManifests derives from VERSIONED_MANIFEST_PATHS)', () => {
|
|
// stageManifests() git-adds [...VERSIONED_MANIFEST_PATHS, ...capabilities]. If
|
|
// marketplace.json were dropped from the paths list, `npm version` would stage
|
|
// every other manifest but silently skip it — so pin the path here too.
|
|
assert.ok(
|
|
VERSIONED_MANIFEST_PATHS.includes(MARKETPLACE_REL),
|
|
`VERSIONED_MANIFEST_PATHS must include ${MARKETPLACE_REL} so stageManifests() stages it on npm version. Got: ${JSON.stringify(VERSIONED_MANIFEST_PATHS)}`
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Section D: nested-path helpers are prototype-pollution-safe ──────────────
|
|
describe('D: getByPath / setByPath reject reserved properties', () => {
|
|
|
|
test('plugins.0.version resolves a nested array-index path', () => {
|
|
const doc = { plugins: [{ version: '1.2.3' }] };
|
|
assert.equal(getByPath(doc, 'plugins.0.version'), '1.2.3');
|
|
});
|
|
|
|
test('getByPath returns undefined for a missing intermediate', () => {
|
|
assert.equal(getByPath({ plugins: [] }, 'plugins.0.version'), undefined);
|
|
});
|
|
|
|
for (const reserved of ['__proto__', 'constructor', 'prototype']) {
|
|
test(`getByPath refuses to traverse "${reserved}"`, () => {
|
|
assert.throws(
|
|
() => getByPath({}, `${reserved}.x`),
|
|
/refusing to traverse reserved property/,
|
|
`getByPath must reject the reserved "${reserved}" segment`
|
|
);
|
|
});
|
|
test(`setByPath refuses to assign through "${reserved}" (no prototype pollution)`, () => {
|
|
const target = {};
|
|
assert.throws(
|
|
() => setByPath(target, `${reserved}.polluted`, 'yes'),
|
|
/refusing to traverse reserved property/,
|
|
`setByPath must reject the reserved "${reserved}" segment`
|
|
);
|
|
// Confirm nothing leaked onto Object.prototype.
|
|
assert.ok(({}).polluted === undefined, 'Object.prototype must not be polluted');
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── Section C2: sync stamps the nested version (temp fixture, red→green) ────
|
|
describe('C2: syncManifestVersions stamps plugins[0].version (temp fixture)', () => {
|
|
|
|
test('stamps a stale marketplace plugins[0].version to the package version, then is idempotent', () => {
|
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1855-'));
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(tmpRoot, 'package.json'),
|
|
JSON.stringify({ name: 'x', version: '9.9.9-test.0' }, null, 2) + '\n'
|
|
);
|
|
// Seed a stale marketplace.json with a nested plugins[0].version.
|
|
const destAbs = path.join(tmpRoot, MARKETPLACE_REL);
|
|
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
|
const stale = JSON.parse(fs.readFileSync(MARKETPLACE_JSON_PATH, 'utf8'));
|
|
stale.plugins[0].version = '0.0.0';
|
|
fs.writeFileSync(destAbs, JSON.stringify(stale, null, 2) + '\n');
|
|
|
|
// Only sync the marketplace manifest in this fixture (other registered
|
|
// manifests are absent under tmpRoot). syncManifestVersions tolerates a
|
|
// missing manifest file by... it does NOT — it readJson-throws. So seed
|
|
// the other registered manifests too (stale) so the sync loop is happy.
|
|
for (const e of VERSIONED_MANIFESTS) {
|
|
const rel = typeof e === 'string' ? e : e.path;
|
|
if (rel === MARKETPLACE_REL) continue;
|
|
const realAbs = path.join(ROOT, rel);
|
|
if (!fs.existsSync(realAbs)) continue;
|
|
const other = JSON.parse(fs.readFileSync(realAbs, 'utf8'));
|
|
const vk = typeof e === 'string' ? 'version' : (e.versionKey || 'version');
|
|
__foldSetNested(other, vk, '0.0.0');
|
|
const d = path.join(tmpRoot, rel);
|
|
fs.mkdirSync(path.dirname(d), { recursive: true });
|
|
fs.writeFileSync(d, JSON.stringify(other, null, 2) + '\n');
|
|
}
|
|
|
|
const changed = syncManifestVersions({ root: tmpRoot });
|
|
assert.ok(changed.includes(MARKETPLACE_REL), `sync should report ${MARKETPLACE_REL} as changed`);
|
|
|
|
const synced = JSON.parse(fs.readFileSync(destAbs, 'utf8'));
|
|
assert.equal(synced.plugins[0].version, '9.9.9-test.0', 'plugins[0].version should be stamped to the package version');
|
|
|
|
// Idempotent second run does not re-report the marketplace manifest.
|
|
const changed2 = syncManifestVersions({ root: tmpRoot });
|
|
assert.ok(!changed2.includes(MARKETPLACE_REL), 'second sync should not re-report an already-synced marketplace manifest');
|
|
} finally {
|
|
helpers.cleanup(tmpRoot);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Minimal nested dot-path setter mirroring the sync script's helper, for fixture seeding.
|
|
function __foldSetNested(obj, dotPath, value) {
|
|
const parts = String(dotPath).split('.');
|
|
let cur = obj;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const k = parts[i];
|
|
cur = cur[k];
|
|
}
|
|
cur[parts[parts.length - 1]] = value;
|
|
}
|
|
});
|
|
}
|