diff --git a/.changeset/sw-039-metadata-conformance-helper.md b/.changeset/sw-039-metadata-conformance-helper.md new file mode 100644 index 0000000..10f3666 --- /dev/null +++ b/.changeset/sw-039-metadata-conformance-helper.md @@ -0,0 +1,5 @@ +--- +'@diginodes/stellarforge-cli': patch +--- + +Add `assertPackageMetadata` and `readPackageJson` to `tests/helpers` (SW-039). The new reusable conformance helper centralises all generated-project package.json assertions — name, scripts, engines.node, absence of dependencies, and absence of embedded secrets — so template tests no longer duplicate that boilerplate. Adopted in `basic-app-package-metadata.test.ts`. diff --git a/package.json b/package.json index 5232ba7..76e291e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "npm": ">=10.9.0" }, "scripts": { - "audit:workflow-permissions": "node scripts/audit-workflow-permissions.mjs" + "audit:workflow-permissions": "node scripts/audit-workflow-permissions.mjs", "build": "tsc -p tsconfig.build.json", "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint .", diff --git a/src/diagnostics/bounds.ts b/src/diagnostics/bounds.ts new file mode 100644 index 0000000..1b85746 --- /dev/null +++ b/src/diagnostics/bounds.ts @@ -0,0 +1,30 @@ +/** + * Bounds constants and utilities for diagnostic text rendering. + * + * These values constrain the maximum size of message and remediation text + * written to the terminal so a failing tool cannot flood the output or reflect + * secret-like content through unbounded text. + */ + +/** Maximum character length for a single diagnostic message. */ +export const MAX_DIAGNOSTIC_MESSAGE_LENGTH = 200; + +/** Maximum character length for a single diagnostic remediation hint. */ +export const MAX_DIAGNOSTIC_REMEDIATION_LENGTH = 200; + +/** + * Truncates `text` to `maxLength` characters, appending an ellipsis when the + * text is longer than the limit. Returns the empty string when `text` is empty. + */ +export function boundDiagnosticText(text: string, maxLength: number): string { + if (!text) { + return ''; + } + + if (text.length <= maxLength) { + return text; + } + + const ellipsis = '...'; + return `${text.slice(0, Math.max(0, maxLength - ellipsis.length))}${ellipsis}`; +} diff --git a/tests/basic-app-package-metadata.test.ts b/tests/basic-app-package-metadata.test.ts index a341ade..525f440 100644 --- a/tests/basic-app-package-metadata.test.ts +++ b/tests/basic-app-package-metadata.test.ts @@ -1,10 +1,10 @@ -import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { describe, expect, it } from 'vitest'; import { createNewCommand } from '../src/commands/new.js'; import { createNpmInvocation } from '../src/process/npm.js'; import { + assertPackageMetadata, createCapturedTerminalOutput, withTempDirectory, } from './helpers/index.js'; @@ -30,7 +30,7 @@ function runNpmScript(projectRoot: string, script: string) { } describe('Basic App package metadata conformance (SW-041)', () => { - it('sets the package name to the generated project name', () => + it('conforms to all frozen package.json metadata constraints', () => withTempDirectory((root) => { const command = createNewCommand({ cwd: () => root, @@ -39,11 +39,33 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - const packageJson = JSON.parse( - readFileSync(join(root, 'my-stellar-app', 'package.json'), 'utf8'), - ) as Record; + assertPackageMetadata( + join(root, 'my-stellar-app', 'package.json'), + { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }, + ); + })); + + it('sets the package name to the generated project name', () => + withTempDirectory((root) => { + const command = createNewCommand({ + cwd: () => root, + output: createCapturedTerminalOutput().output, + }); + + command.parse(['node', 'new', 'my-stellar-app']); - expect(packageJson['name']).toBe('my-stellar-app'); + assertPackageMetadata( + join(root, 'my-stellar-app', 'package.json'), + { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }, + ); })); it('retains all frozen script entries without modification', () => @@ -55,11 +77,14 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - const packageJson = JSON.parse( - readFileSync(join(root, 'my-stellar-app', 'package.json'), 'utf8'), - ) as Record; - - expect(packageJson['scripts']).toEqual(FROZEN_SCRIPTS); + assertPackageMetadata( + join(root, 'my-stellar-app', 'package.json'), + { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }, + ); })); it('retains only the frozen script keys and no extras', () => @@ -71,14 +96,13 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - const packageJson = JSON.parse( - readFileSync(join(root, 'my-stellar-app', 'package.json'), 'utf8'), - ) as Record; - - const scripts = packageJson['scripts'] as Record; - - expect(Object.keys(scripts).sort()).toEqual( - Object.keys(FROZEN_SCRIPTS).sort(), + assertPackageMetadata( + join(root, 'my-stellar-app', 'package.json'), + { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }, ); })); @@ -91,14 +115,14 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - const packageJson = JSON.parse( - readFileSync(join(root, 'my-stellar-app', 'package.json'), 'utf8'), - ) as Record; - - const engines = packageJson['engines'] as Record; - - expect(engines).toBeDefined(); - expect(engines['node']).toBe(FROZEN_NODE_ENGINE); + assertPackageMetadata( + join(root, 'my-stellar-app', 'package.json'), + { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }, + ); })); it('generates no dependencies field', () => @@ -110,11 +134,14 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - const packageJson = JSON.parse( - readFileSync(join(root, 'my-stellar-app', 'package.json'), 'utf8'), - ) as Record; - - expect(packageJson['dependencies']).toBeUndefined(); + assertPackageMetadata( + join(root, 'my-stellar-app', 'package.json'), + { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }, + ); })); it('generates no devDependencies field', () => @@ -126,11 +153,14 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - const packageJson = JSON.parse( - readFileSync(join(root, 'my-stellar-app', 'package.json'), 'utf8'), - ) as Record; - - expect(packageJson['devDependencies']).toBeUndefined(); + assertPackageMetadata( + join(root, 'my-stellar-app', 'package.json'), + { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }, + ); })); it('does not embed secret, seed, or private-key values in the generated package.json', () => @@ -142,14 +172,14 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - const raw = readFileSync( + assertPackageMetadata( join(root, 'my-stellar-app', 'package.json'), - 'utf8', + { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }, ); - - expect(raw).not.toMatch(/SECRET|SEED|PRIVATE_KEY/); - // Stellar secret key strkeys start with 'S' and are 56 uppercase base32 chars - expect(raw).not.toMatch(/S[A-Z0-9]{55}/); })); it( @@ -164,6 +194,14 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); const projectRoot = join(root, 'my-stellar-app'); + + // Confirm metadata is still conformant, then exercise the check script. + assertPackageMetadata(join(projectRoot, 'package.json'), { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }); + const result = runNpmScript(projectRoot, 'check'); expect(result.status, result.stderr).toBe(0); diff --git a/tests/helpers/index.ts b/tests/helpers/index.ts index 6705053..f7e0c2c 100644 --- a/tests/helpers/index.ts +++ b/tests/helpers/index.ts @@ -43,3 +43,9 @@ export { SECRET_STRKEY_FIXTURE, TOKEN_FIXTURE, } from './secret-fixtures.js'; +export { + assertPackageMetadata, + readPackageJson, + type ExpectedScripts, + type PackageMetadataOptions, +} from './package-metadata.js'; diff --git a/tests/helpers/package-metadata.ts b/tests/helpers/package-metadata.ts new file mode 100644 index 0000000..4691ad2 --- /dev/null +++ b/tests/helpers/package-metadata.ts @@ -0,0 +1,177 @@ +/** + * Package metadata conformance helper (SW-039). + * + * Provides deterministic assertions for generated project package.json files + * without duplicating boilerplate across template tests. + * + * Usage: + * import { assertPackageMetadata } from './helpers/index.js'; + * + * assertPackageMetadata(join(projectRoot, 'package.json'), { + * name: 'my-app', + * scripts: { start: 'node src/index.js', test: 'node --test' }, + * nodeEngine: '>=22.13.0', + * }); + */ + +import { readFileSync } from 'node:fs'; +import { expect } from 'vitest'; + +/** Shape of the scripts record expected in a generated package.json. */ +export type ExpectedScripts = Readonly>; + +/** Options accepted by {@link assertPackageMetadata}. */ +export interface PackageMetadataOptions { + /** + * Expected value of the `name` field. + * Asserted with strict equality against the generated project name. + */ + readonly name: string; + + /** + * Exact scripts object the generated package.json must contain. + * The helper asserts both values and that no extra keys are present. + */ + readonly scripts: ExpectedScripts; + + /** + * Expected value of `engines.node`. + * Asserted with strict equality. + */ + readonly nodeEngine: string; + + /** + * When true, skip the check that `dependencies` is absent. + * Useful for templates that intentionally have runtime dependencies. + * Defaults to false. + */ + readonly allowDependencies?: boolean; + + /** + * When true, skip the check that `devDependencies` is absent. + * Defaults to false. + */ + readonly allowDevDependencies?: boolean; +} + +/** + * Raw representation of a generated package.json. + * Only the fields the helper cares about are typed; all others are `unknown`. + */ +interface RawPackageJson { + name?: unknown; + scripts?: unknown; + engines?: unknown; + dependencies?: unknown; + devDependencies?: unknown; + [key: string]: unknown; +} + +/** + * Read and parse a package.json file, throwing a descriptive error when the + * file is missing or malformed so test failures pinpoint the problem. + */ +export function readPackageJson(packageJsonPath: string): RawPackageJson { + let raw: string; + + try { + raw = readFileSync(packageJsonPath, 'utf8'); + } catch (cause) { + throw new Error( + `package-metadata helper: cannot read '${packageJsonPath}': ${String(cause)}`, + { cause }, + ); + } + + try { + return JSON.parse(raw) as RawPackageJson; + } catch (cause) { + throw new Error( + `package-metadata helper: '${packageJsonPath}' is not valid JSON: ${String(cause)}`, + { cause }, + ); + } +} + +/** + * Assert that the generated package.json at `packageJsonPath` conforms to the + * supplied `options`. + * + * Checks performed (in order): + * 1. `name` equals `options.name`. + * 2. `scripts` values match `options.scripts` exactly (no extra or missing keys). + * 3. `engines.node` equals `options.nodeEngine`. + * 4. `dependencies` is absent (unless `allowDependencies` is true). + * 5. `devDependencies` is absent (unless `allowDevDependencies` is true). + * 6. The raw file content contains no `SECRET`, `SEED`, or `PRIVATE_KEY` literals. + * 7. The raw file content contains no Stellar secret key strkey pattern (`S[A-Z0-9]{55}`). + */ +export function assertPackageMetadata( + packageJsonPath: string, + options: PackageMetadataOptions, +): void { + const pkg = readPackageJson(packageJsonPath); + const raw = readFileSync(packageJsonPath, 'utf8'); + + // 1. Package name + expect(pkg['name'], `package.json 'name' at ${packageJsonPath}`).toBe( + options.name, + ); + + // 2. Scripts — exact values + expect(pkg['scripts'], `package.json 'scripts' at ${packageJsonPath}`).toEqual( + options.scripts, + ); + + // 2b. Scripts — no extra keys beyond what is expected + const actualScriptKeys = Object.keys( + (pkg['scripts'] ?? {}) as Record, + ).sort(); + const expectedScriptKeys = Object.keys(options.scripts).sort(); + + expect( + actualScriptKeys, + `package.json 'scripts' keys at ${packageJsonPath}`, + ).toEqual(expectedScriptKeys); + + // 3. engines.node + const engines = pkg['engines'] as Record | undefined; + + expect( + engines, + `package.json 'engines' field missing at ${packageJsonPath}`, + ).toBeDefined(); + + expect( + engines?.['node'], + `package.json 'engines.node' at ${packageJsonPath}`, + ).toBe(options.nodeEngine); + + // 4. No runtime dependencies (unless explicitly allowed) + if (!options.allowDependencies) { + expect( + pkg['dependencies'], + `package.json should have no 'dependencies' at ${packageJsonPath}`, + ).toBeUndefined(); + } + + // 5. No dev dependencies (unless explicitly allowed) + if (!options.allowDevDependencies) { + expect( + pkg['devDependencies'], + `package.json should have no 'devDependencies' at ${packageJsonPath}`, + ).toBeUndefined(); + } + + // 6. No secret-related literals + expect( + raw, + `package.json must not embed SECRET/SEED/PRIVATE_KEY at ${packageJsonPath}`, + ).not.toMatch(/SECRET|SEED|PRIVATE_KEY/); + + // 7. No Stellar secret key strkeys (56-char uppercase base32 starting with S) + expect( + raw, + `package.json must not embed a Stellar secret key strkey at ${packageJsonPath}`, + ).not.toMatch(/S[A-Z0-9]{55}/); +}