diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d34c8d..fd7f9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project are documented in this file. +## [0.2.1] + +Presentation-only delight pass on the local stack report. No change to what +is scanned, no new commands or flags, and `--json`/`--markdown` output is +byte-for-byte unchanged apart from the `cli_version` field. + +- Terminal report: type labels (`mcp`/`plugin`/`skill`/`subagent`) get a + cyan accent and per-type counts are bold, TTY-only and `NO_COLOR`-aware +- Empty state: warmer copy ("No AI tooling detected on this machine yet.") + and `Looked in:` paths now print home-relative (`~/.claude/skills`) +- Fixed `NO_COLOR=''` (empty value) not being treated as "set" — the + no-color.org standard disables color on presence, not truthiness + ## [0.2.0] Standalone-first re-aim: local stack report for Claude Code, Codex, and Cursor, including skills and subagents. diff --git a/README.md b/README.md index acb3070..5a90632 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ npx devcat-cli --json | jq '.clients[] | {label, total}' ```json { - "cli_version": "0.2.0", + "cli_version": "0.2.1", "total": 23, "project_scoped": 2, "user_scoped": 21, diff --git a/package-lock.json b/package-lock.json index 6adfbb7..4ad1c18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devcat-cli", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devcat-cli", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "@napi-rs/keyring": "^1.2.0", diff --git a/package.json b/package.json index 8a1177d..425f943 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devcat-cli", - "version": "0.2.0", + "version": "0.2.1", "description": "See your whole AI-coding stack in one command. npx devcat-cli scans this machine for the MCP servers, plugins, skills, and subagents installed across Claude Code, Codex, and Cursor and prints them grouped — locally, with no account and no network call.", "license": "MIT", "author": "Andrew Noble (https://github.com/AnobleSCM)", diff --git a/src/lib/isHeadless.ts b/src/lib/isHeadless.ts index 0ddb43a..13567f8 100644 --- a/src/lib/isHeadless.ts +++ b/src/lib/isHeadless.ts @@ -35,7 +35,9 @@ export function detectEnv(): EnvFlags { !!process.env.BUILDKITE || !!process.env.CIRCLECI || !!process.env.JENKINS_URL, - noColor: !!process.env.NO_COLOR, + // Presence, not truthiness: no-color.org disables color "when [NO_COLOR + // is] present (regardless of its value)", so NO_COLOR='' must count too. + noColor: process.env.NO_COLOR !== undefined, }; } diff --git a/src/ui/report.ts b/src/ui/report.ts index 92163c0..88f4a0f 100644 --- a/src/ui/report.ts +++ b/src/ui/report.ts @@ -1,3 +1,5 @@ +import { homedir } from 'node:os'; +import { sep } from 'node:path'; import type { DetectResult, ToolEntry, ToolClient, RootTruncation } from '../manifest/index.js'; import { READ_CEILING } from '../manifest/index.js'; import { CLI_VERSION } from '../version.js'; @@ -75,6 +77,46 @@ function sanitizeMarkdownName(name: string): string { return sanitizeName(name).replace(/`/g, ''); } +/** Whether the home-prefix comparison in {@link toHomeRelative} folds case. */ +export type PathCaseSensitivity = 'sensitive' | 'insensitive'; + +/** + * win32 and macOS both default to case-insensitive (but case-preserving) + * filesystems, so a homedir() or $HOME that is cased differently from a + * scanned path can still name the same directory. Linux filesystems are + * case-sensitive, so a cased difference there is a genuinely different path. + */ +function defaultPathCaseSensitivity(platform: NodeJS.Platform = process.platform): PathCaseSensitivity { + return platform === 'win32' || platform === 'darwin' ? 'insensitive' : 'sensitive'; +} + +/** + * Display-only: rewrite a path under `home` to start with `~`. Requires a + * separator (or exact equality) at the boundary so a sibling directory that + * merely shares the prefix — /Users/name2 next to /Users/name — is left + * absolute rather than mangled into ~2. + * + * The prefix comparison is case-insensitive on win32/darwin and + * case-sensitive elsewhere (see {@link defaultPathCaseSensitivity}) — this + * is a policy about matching, not about display: casing is folded only to + * decide whether `path` sits under `home`, never to build the return value. + * The output always keeps `path`'s original casing; the matched prefix span + * is simply discarded in favor of `~`, not re-cased. `caseSensitivity` + * defaults to the real platform policy but is an explicit parameter so tests + * can exercise both modes deterministically regardless of which OS actually + * runs them. + */ +export function toHomeRelative( + path: string, + home: string, + caseSensitivity: PathCaseSensitivity = defaultPathCaseSensitivity(), +): string { + const fold = caseSensitivity === 'insensitive' ? (s: string) => s.toLowerCase() : (s: string) => s; + if (fold(path) === fold(home)) return '~'; + const prefix = home.endsWith(sep) ? home : `${home}${sep}`; + return fold(path).startsWith(fold(prefix)) ? `~${sep}${path.slice(prefix.length)}` : path; +} + /** * Group detected tools by client, then by type, with names sorted * alphabetically. Clients and types with nothing in them are omitted. @@ -122,7 +164,9 @@ export function renderStackReport(result: DetectResult): string { lines.push(''); lines.push(`${c.bold(group.label)} ${c.dim(`· ${plural(group.total, 'tool')}`)}`); for (const { type, names } of group.byType) { - const prefix = ` ${String(names.length).padStart(3)} ${type.padEnd(TYPE_WIDTH)}`; + const count = c.bold(String(names.length).padStart(3)); + const label = c.cyan(type.padEnd(TYPE_WIDTH)); + const prefix = ` ${count} ${label}`; const wrapped = wrap(names.map(sanitizeName).join(', '), WRAP_WIDTH - NAME_COLUMN); lines.push(`${prefix}${wrapped[0]}`); for (const cont of wrapped.slice(1)) { @@ -280,12 +324,17 @@ const MARKDOWN_FOOTER = /** * Nothing found. Print the paths that were checked so the user can spot the - * config location the CLI does not know about yet. + * config location the CLI does not know about yet. Paths under $HOME print + * home-relative (~/.claude/skills) — display only; --json's paths_checked + * stays absolute for scripts. */ function renderEmptyStack(result: DetectResult): string { - const list = result.pathsScanned.map((p) => ` - ${sanitizeName(p)}`).join('\n'); + const home = homedir(); + const list = result.pathsScanned + .map((p) => ` - ${toHomeRelative(sanitizeName(p), home)}`) + .join('\n'); const lines = [ - `${SUCCESS_GLYPH} ${c.bold('No AI tooling detected.')}`, + `${SUCCESS_GLYPH} ${c.bold('No AI tooling detected on this machine yet.')}`, '', 'Looked in:', list, diff --git a/src/version.ts b/src/version.ts index 209908e..da186b9 100644 --- a/src/version.ts +++ b/src/version.ts @@ -4,4 +4,4 @@ * Sent as cli_version on POST /api/device/token per Phase 40 D-06. * Server validates against semver regex /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/. */ -export const CLI_VERSION = '0.2.0'; +export const CLI_VERSION = '0.2.1'; diff --git a/test/unit/api/client.test.ts b/test/unit/api/client.test.ts index 0c28a07..1e66bc6 100644 --- a/test/unit/api/client.test.ts +++ b/test/unit/api/client.test.ts @@ -20,7 +20,7 @@ describe('authenticatedFetch', () => { }), ); await authenticatedFetch(`${API_BASE}/probe`); - expect(capturedUA).toMatch(/^devcat-cli\/0\.2\.0 \((darwin|linux|win32); node /); + expect(capturedUA).toMatch(/^devcat-cli\/0\.2\.1 \((darwin|linux|win32); node /); }); it('sets Authorization: Bearer when bearerToken passed', async () => { diff --git a/test/unit/auth/deviceFlow.test.ts b/test/unit/auth/deviceFlow.test.ts index 90931e6..9938889 100644 --- a/test/unit/auth/deviceFlow.test.ts +++ b/test/unit/auth/deviceFlow.test.ts @@ -263,7 +263,7 @@ describe('pollForToken', () => { const body = capturedBody as { client_metadata?: { cli_version?: string; hostname?: string; platform?: string }; }; - expect(body.client_metadata?.cli_version).toBe('0.2.0'); + expect(body.client_metadata?.cli_version).toBe('0.2.1'); expect(['darwin', 'linux', 'win32']).toContain(body.client_metadata?.platform); expect(typeof body.client_metadata?.hostname).toBe('string'); }); diff --git a/test/unit/lib/isHeadless.test.ts b/test/unit/lib/isHeadless.test.ts index c4a694e..1422511 100644 --- a/test/unit/lib/isHeadless.test.ts +++ b/test/unit/lib/isHeadless.test.ts @@ -33,7 +33,17 @@ describe('detectEnv', () => { it('reads NO_COLOR env var into noColor flag', () => { vi.stubEnv('NO_COLOR', '1'); expect(detectEnv().noColor).toBe(true); + }); + + it('treats an empty-string NO_COLOR as present, per the no-color.org spec', () => { + // no-color.org: disable color "when [NO_COLOR is] present (regardless of + // its value)" — presence, not truthiness, is what must gate this. vi.stubEnv('NO_COLOR', ''); + expect(detectEnv().noColor).toBe(true); + }); + + it('is false when NO_COLOR is not present at all', () => { + vi.stubEnv('NO_COLOR', undefined); expect(detectEnv().noColor).toBe(false); }); diff --git a/test/unit/ui/colors.test.ts b/test/unit/ui/colors.test.ts new file mode 100644 index 0000000..890ba5f --- /dev/null +++ b/test/unit/ui/colors.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; + +/** + * colors.ts computes its `enabled` flag once at module load time from + * process.stdout.isTTY + NO_COLOR, so exercising both the colored and plain + * paths in one run requires a fresh module graph per scenario: + * vi.resetModules() then a dynamic import, after setting the env that + * decides `enabled`. The rest of the suite relies on isTTY being falsy in + * Vitest's fork pool (colors auto-strip); this file is where the TTY-colored + * path itself gets exercised. + */ + +const ESC = String.fromCharCode(27); + +const ORIGINAL_ISTTY = process.stdout.isTTY; +const ORIGINAL_NO_COLOR = process.env.NO_COLOR; + +afterEach(() => { + if (ORIGINAL_ISTTY === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY; + else process.stdout.isTTY = ORIGINAL_ISTTY; + if (ORIGINAL_NO_COLOR === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = ORIGINAL_NO_COLOR; + vi.resetModules(); +}); + +async function loadColors(opts: { isTTY: boolean; noColor?: string }) { + process.stdout.isTTY = opts.isTTY; + if (opts.noColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = opts.noColor; + vi.resetModules(); + return import('../../../src/ui/colors.js'); +} + +describe('colors — TTY + NO_COLOR gating', () => { + it('emits ANSI codes on a real TTY with NO_COLOR unset', async () => { + const { c, SUCCESS_GLYPH, FAILURE_GLYPH } = await loadColors({ isTTY: true }); + expect(c.cyan('mcp')).toContain(`${ESC}[`); + expect(c.bold('7')).toContain(`${ESC}[`); + expect(c.dim('x')).toContain(`${ESC}[`); + expect(SUCCESS_GLYPH).toContain(`${ESC}[`); + expect(FAILURE_GLYPH).toContain(`${ESC}[`); + }); + + it('stays plain when stdout is not a TTY', async () => { + const { c, SUCCESS_GLYPH, FAILURE_GLYPH } = await loadColors({ isTTY: false }); + expect(c.cyan('mcp')).toBe('mcp'); + expect(c.bold('7')).toBe('7'); + expect(SUCCESS_GLYPH).toBe('✓'); + expect(FAILURE_GLYPH).toBe('✗'); + }); + + it('NO_COLOR forces plain output even on a TTY', async () => { + const { c, SUCCESS_GLYPH } = await loadColors({ isTTY: true, noColor: '1' }); + expect(c.cyan('mcp')).toBe('mcp'); + expect(c.bold('7')).toBe('7'); + expect(SUCCESS_GLYPH).toBe('✓'); + }); + + it('an empty-string NO_COLOR still forces plain output on a TTY', async () => { + const { c } = await loadColors({ isTTY: true, noColor: '' }); + expect(c.cyan('mcp')).toBe('mcp'); + }); +}); diff --git a/test/unit/ui/report.test.ts b/test/unit/ui/report.test.ts index 2faa199..04f682a 100644 --- a/test/unit/ui/report.test.ts +++ b/test/unit/ui/report.test.ts @@ -1,10 +1,12 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { sep, join } from 'node:path'; import { groupStack, renderStackReport, renderStackMarkdown, renderStackJson, truncationWarnings, + toHomeRelative, } from '../../../src/ui/report.js'; import type { DetectResult, ToolEntry } from '../../../src/manifest/index.js'; @@ -12,6 +14,17 @@ import type { DetectResult, ToolEntry } from '../../../src/manifest/index.js'; // NO_COLOR is belt-and-suspenders for any CI shape where isTTY is truthy. process.env.NO_COLOR = '1'; +// Only the home-relative-path tests below care what homedir() returns; every +// other test in this file uses fixture paths that never match a real home +// dir, so this mock is a no-op for them (same pattern as the integration +// suite's homedirHolder). +const homedirHolder: { current: string | null } = { current: null }; + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => homedirHolder.current ?? actual.homedir() }; +}); + function tool(partial: Partial & Pick): ToolEntry { return { type: 'mcp', @@ -127,13 +140,121 @@ describe('renderStackReport (default `npx devcat-cli` output)', () => { it('empty scan: names the paths it checked instead of printing an empty stack', () => { const out = renderStackReport({ tools: [], pathsScanned: ['/p/.mcp.json', '~/.cursor/mcp.json'], truncations: [] }); - expect(out).toContain('No AI tooling detected.'); + expect(out).toContain('No AI tooling detected on this machine yet.'); expect(out).toContain('/p/.mcp.json'); expect(out).toContain('~/.cursor/mcp.json'); expect(out).not.toContain('Your AI-coding stack'); }); }); +describe('renderEmptyStack — copy and home-relative paths (terminal report only)', () => { + // Built from node:path primitives rather than hardcoded POSIX literals: + // toHomeRelative keys off path.sep, and a hardcoded '/' fixture silently + // no-ops the whole boundary check on win32 (sep is '\\' there), which is + // exactly how this suite went green on Unix while failing on Windows CI. + const HOME = join(sep, 'Users', 'testuser'); + + beforeEach(() => { + homedirHolder.current = HOME; + }); + + afterEach(() => { + homedirHolder.current = null; + }); + + it('uses warm, honest, marketing-free copy', () => { + const out = renderStackReport({ tools: [], pathsScanned: [join(HOME, '.claude.json')], truncations: [] }); + expect(out).toContain('No AI tooling detected on this machine yet.'); + }); + + it('keeps the Looked in: transparency section', () => { + const out = renderStackReport({ tools: [], pathsScanned: [join(HOME, '.claude.json')], truncations: [] }); + expect(out).toContain('Looked in:'); + }); + + it('shows paths under $HOME as home-relative', () => { + const out = renderStackReport({ + tools: [], + pathsScanned: [join(HOME, '.claude.json'), join(HOME, '.claude', 'skills')], + truncations: [], + }); + expect(out).toContain(`~${sep}.claude.json`); + expect(out).toContain(`~${sep}.claude${sep}skills`); + expect(out).not.toContain(HOME); + }); + + it('leaves paths outside $HOME absolute', () => { + const outside = join(sep, 'opt', 'shared', '.mcp.json'); + const out = renderStackReport({ + tools: [], + pathsScanned: [outside], + truncations: [], + }); + expect(out).toContain(outside); + }); + + it('does not rewrite a sibling directory that merely shares the home prefix', () => { + // HOME + '2' (e.g. /Users/testuser2) must not become ~2 from a naive + // (non-boundary) prefix match. + const sibling = `${HOME}2${sep}.mcp.json`; + const out = renderStackReport({ + tools: [], + pathsScanned: [sibling], + truncations: [], + }); + expect(out).toContain(sibling); + }); + + it('renders the home directory itself as bare ~', () => { + const out = renderStackReport({ tools: [], pathsScanned: [HOME], truncations: [] }); + expect(out).toContain(' - ~'); + }); + + it('does not home-relativize paths_checked in --json output', () => { + const parsed = JSON.parse( + renderStackJson({ tools: [], pathsScanned: [join(HOME, '.claude.json')], truncations: [] }), + ); + expect(parsed.paths_checked).toEqual([join(HOME, '.claude.json')]); + }); +}); + +describe('toHomeRelative — case-sensitivity policy', () => { + // caseSensitivity is an explicit parameter precisely so these can run + // deterministically on every CI lane instead of only asserting whatever + // the host platform's own case-folding happens to do. + const HOME = join(sep, 'Users', 'testuser'); + + it('matches a differently-cased home prefix in insensitive mode (win32/darwin policy)', () => { + const shouted = `${HOME.toUpperCase()}${sep}.claude.json`; + expect(toHomeRelative(shouted, HOME, 'insensitive')).toBe(`~${sep}.claude.json`); + }); + + it('does not match a differently-cased home prefix in sensitive mode (linux policy)', () => { + const shouted = `${HOME.toUpperCase()}${sep}.claude.json`; + expect(toHomeRelative(shouted, HOME, 'sensitive')).toBe(shouted); + }); + + it('preserves the original path casing beyond the matched prefix', () => { + // The comparison folds case; the output must not — only the matched + // home-prefix span is replaced by ~, the remainder prints as scanned. + const shouted = `${HOME.toUpperCase()}${sep}MixedCase.JSON`; + expect(toHomeRelative(shouted, HOME, 'insensitive')).toBe(`~${sep}MixedCase.JSON`); + }); + + it('holds the sibling-directory boundary guard case-insensitively', () => { + // home '/users/name' + path '/Users/NAME2/x' must stay absolute even + // when folding case — NAME2 is a sibling, not home itself. + const home = join(sep, 'users', 'name'); + const sibling = `${join(sep, 'Users', 'NAME')}2${sep}x`; + expect(toHomeRelative(sibling, home, 'insensitive')).toBe(sibling); + }); + + it('exact-equality shortcut also folds case in insensitive mode', () => { + expect(toHomeRelative(HOME.toUpperCase(), HOME, 'insensitive')).toBe('~'); + expect(toHomeRelative(HOME.toUpperCase(), HOME, 'sensitive')).toBe(HOME.toUpperCase()); + }); +}); + describe('renderStackMarkdown (--markdown)', () => { it('emits a "My AI stack" snippet with a section per client', () => { const out = renderStackMarkdown(MIXED); @@ -291,7 +412,7 @@ describe('truncation disclosure', () => { pathsScanned: ['~/.claude/skills'], truncations: TRUNCATED.truncations, }); - expect(out).toContain('No AI tooling detected.'); + expect(out).toContain('No AI tooling detected on this machine yet.'); expect(out).toContain('1 location was truncated'); expect(out).toContain('some tools are not listed'); }); diff --git a/test/unit/ui/report.tty-color.test.ts b/test/unit/ui/report.tty-color.test.ts new file mode 100644 index 0000000..23111a1 --- /dev/null +++ b/test/unit/ui/report.tty-color.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { DetectResult, ToolEntry } from '../../../src/manifest/index.js'; + +/** + * report.ts colorizes through colors.ts, whose `enabled` flag is fixed at + * module load time — so the colored path needs a fresh module graph per + * scenario (vi.resetModules() + dynamic import), same technique as + * colors.test.ts. report.test.ts covers everything else statically, relying + * on isTTY being falsy in Vitest's fork pool; this file is the one place the + * TTY-colored render itself gets exercised end to end. + */ + +const ESC = String.fromCharCode(27); + +const ORIGINAL_ISTTY = process.stdout.isTTY; +const ORIGINAL_NO_COLOR = process.env.NO_COLOR; + +afterEach(() => { + if (ORIGINAL_ISTTY === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY; + else process.stdout.isTTY = ORIGINAL_ISTTY; + if (ORIGINAL_NO_COLOR === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = ORIGINAL_NO_COLOR; + vi.resetModules(); +}); + +function tool(partial: Partial & Pick): ToolEntry { + return { type: 'mcp', source: '/fake/path.json', scope: 'user', client: 'claude-code', ...partial }; +} + +const SAMPLE: DetectResult = { + tools: [tool({ name: 'context7' }), tool({ name: 'swift-lsp', type: 'plugin' })], + pathsScanned: ['/home/x/.claude.json'], + truncations: [], +}; + +/** Strips `ESC [ ... m` SGR sequences without a control-character regex. */ +function stripAnsi(s: string): string { + let result = ''; + let i = 0; + while (i < s.length) { + if (s[i] === ESC && s[i + 1] === '[') { + const end = s.indexOf('m', i); + i = end === -1 ? s.length : end + 1; + } else { + result += s[i]; + i += 1; + } + } + return result; +} + +async function renderWith(opts: { isTTY: boolean; noColor?: string }): Promise { + process.stdout.isTTY = opts.isTTY; + if (opts.noColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = opts.noColor; + vi.resetModules(); + const { renderStackReport } = await import('../../../src/ui/report.js'); + return renderStackReport(SAMPLE); +} + +describe('renderStackReport — color hierarchy (TTY only)', () => { + it('colorizes type labels cyan and emphasizes counts bold on a real TTY', async () => { + const out = await renderWith({ isTTY: true }); + expect(out).toContain(`${ESC}[`); + expect(out).toContain(`${ESC}[36mmcp`); + expect(out).toContain(`${ESC}[36mplugin`); + expect(out).toContain(`${ESC}[1m`); + expect(out).toContain(`1${ESC}[22m`); + }); + + it('never colors the name list itself, only the type label and the count', async () => { + const out = await renderWith({ isTTY: true }); + expect(out).not.toContain(`${ESC}[36mcontext7`); + expect(out).not.toContain(`${ESC}[36mswift-lsp`); + }); + + it('colorizing never changes the underlying text — stripped output matches the plain render', async () => { + const colored = await renderWith({ isTTY: true }); + const plain = await renderWith({ isTTY: false }); + expect(stripAnsi(colored)).toBe(plain); + }); + + it('stays fully plain when stdout is not a TTY', async () => { + const out = await renderWith({ isTTY: false }); + expect(out).not.toContain(`${ESC}[`); + expect(out).toContain('1 mcp'); + expect(out).toContain('1 plugin'); + }); + + it('NO_COLOR forces plain output even on a TTY', async () => { + const out = await renderWith({ isTTY: true, noColor: '1' }); + expect(out).not.toContain(`${ESC}[`); + expect(out).toContain('1 mcp'); + }); +}); diff --git a/test/unit/version.parity.test.ts b/test/unit/version.parity.test.ts index 243e258..11c9b13 100644 --- a/test/unit/version.parity.test.ts +++ b/test/unit/version.parity.test.ts @@ -10,4 +10,19 @@ describe('CLI_VERSION parity', () => { expect(CLI_VERSION).toBe(packageJson.version); }); + + // A lockfile regenerated only in part (e.g. hand-editing package.json + // without ever re-running npm install) silently drifts from the source of + // truth. Both the top-level version and packages[""].version must track + // package.json, or a future bump can land half-done again. + it('matches package-lock.json top-level version and packages[""].version', () => { + const packageJsonPath = join(__dirname, '..', '..', 'package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + + const packageLockPath = join(__dirname, '..', '..', 'package-lock.json'); + const packageLock = JSON.parse(readFileSync(packageLockPath, 'utf8')); + + expect(packageLock.version).toBe(packageJson.version); + expect(packageLock.packages['']?.version).toBe(packageJson.version); + }); });