diff --git a/lib/modules/downloadModule.js b/lib/modules/downloadModule.js index 02afa3da..03930eec 100644 --- a/lib/modules/downloadModule.js +++ b/lib/modules/downloadModule.js @@ -6,7 +6,8 @@ import Portal from '../portal.js'; import fs from 'fs'; import path from 'path'; import os from 'os'; -import { getModulesDir, getModulePath } from './paths.js'; +import { getModulesDir, getModulePath, POS_MODULE_FILE, TEMPLATE_VALUES_FILE } from './paths.js'; +import { safeReadFile } from './postInstall.js'; /** * Downloads and extracts a single module archive. @@ -52,32 +53,49 @@ const downloadAllModules = async (modules, getRegistryUrl, fetchVersions = null) ); }; +const readJsonVersion = (filePath) => safeReadFile(filePath, (raw) => JSON.parse(raw).version ?? null); + /** - * Returns the subset of modulesLocked that actually needs to be downloaded. - * - * A module is skipped when BOTH of the following are true: - * 1. Its version in previousLock matches the newly resolved version (no change). - * 2. Its directory already exists on disk (a previous download succeeded). - * - * The disk check catches the case where the lock file is up-to-date but the - * module directory was deleted manually — in that case we must re-download. + * Reads the `version` field recorded in an installed module's own manifest: + * modules//pos-module.json, falling back to modules//template-values.json + * for modules published before the pos-module.json convention existed (many + * currently-published registry modules still ship this way). Returns null when + * neither file exists, is readable, or carries a `version` field — treated the + * same as "not installed" by callers. Uses the same safe-read primitive as + * postInstall.js's module-manifest lookups (postInstall.js's safeReadFile). */ -const modulesToDownload = (modulesLocked, previousLock) => - Object.fromEntries( - Object.entries(modulesLocked).filter(([name, version]) => { - if (previousLock[name] !== version) return true; - return !fs.existsSync(getModulePath(name)); - }) - ); +const readInstalledVersion = (name) => { + const dir = getModulePath(name); + return readJsonVersion(path.join(dir, POS_MODULE_FILE)) ?? readJsonVersion(path.join(dir, TEMPLATE_VALUES_FILE)); +}; /** - * Returns the subset of modules whose directory is missing from disk. - * Used by --frozen mode where the lock is already the source of truth and - * there is no "previous lock" to compare versions against. + * Returns the subset of modules whose installed disk version does not match + * the target version — including modules missing from disk entirely. Used by + * --frozen mode (and smartInstall's fast path) where the lock is already the + * source of truth and there is no "previous lock" to compare versions against. + * + * Checking installed disk version rather than mere directory presence catches + * modules whose directory exists but whose contents are stale or corrupted — + * e.g. deleted manually then recreated empty, a failed/partial extraction, or + * simply never updated after the lock file itself was bumped (by a teammate, + * a merge, etc.) without the module directory being refreshed locally. */ const modulesNotOnDisk = (modules) => Object.fromEntries( - Object.entries(modules).filter(([name]) => !fs.existsSync(getModulePath(name))) + Object.entries(modules).filter(([name, version]) => readInstalledVersion(name) !== version) ); -export { downloadModule, downloadAllModules, modulesToDownload, modulesNotOnDisk }; +/** + * Returns the subset of modulesLocked that actually needs to be downloaded: + * everything modulesNotOnDisk flags, plus any module whose version in + * previousLock no longer matches the newly resolved version. + */ +const modulesToDownload = (modulesLocked, previousLock) => ({ + ...Object.fromEntries( + Object.entries(modulesLocked).filter(([name, version]) => previousLock[name] !== version) + ), + ...modulesNotOnDisk(modulesLocked), +}); + +export { downloadModule, downloadAllModules, modulesToDownload, modulesNotOnDisk, readInstalledVersion }; diff --git a/lib/modules/orchestrator.js b/lib/modules/orchestrator.js index b83da6dd..4eca7195 100644 --- a/lib/modules/orchestrator.js +++ b/lib/modules/orchestrator.js @@ -144,8 +144,9 @@ const isLockValidForInstall = (lock, prodModules, devModules, includeDev) => * - any dep in pos-module.json is absent from the lock file (lock is stale) * - any locked version does not satisfy the constraint declared in pos-module.json * - * Downloads only modules that are missing from disk; already-present modules - * at the locked version are skipped, making it safe to cache `modules/` in CI. + * Downloads only modules that are missing from disk or whose installed version + * doesn't match the lock; already-present modules at the locked version are + * skipped, making it safe to cache `modules/` in CI. * * @param {ora.Ora} spinner * @param {Object} prodModules - dependencies from pos-module.json diff --git a/lib/modules/paths.js b/lib/modules/paths.js index f6bce334..4cf63600 100644 --- a/lib/modules/paths.js +++ b/lib/modules/paths.js @@ -6,6 +6,7 @@ import path from 'path'; export const POS_MODULE_FILE = 'pos-module.json'; +export const TEMPLATE_VALUES_FILE = 'template-values.json'; export const POS_MODULE_LOCK_FILE = 'pos-module.lock.json'; export const LEGACY_POS_MODULES_FILE = 'app/pos-modules.json'; export const LEGACY_POS_MODULES_LOCK_FILE = 'app/pos-modules.lock.json'; diff --git a/lib/modules/postInstall.js b/lib/modules/postInstall.js index ea905158..97710b0e 100644 --- a/lib/modules/postInstall.js +++ b/lib/modules/postInstall.js @@ -63,7 +63,8 @@ const normalize = (raw) => { * Reads and transforms a file, returning null when it is absent or unreadable. * A single read (no existsSync pre-check) handles the missing-file case via the * ENOENT catch; any other read/parse failure is logged at Debug and treated as - * "no message" so a broken module never aborts the install. + * absent, so a broken or missing file never aborts the caller (post-install + * messages, installed-version lookups, etc.). */ const safeReadFile = (filePath, transform) => { try { @@ -134,4 +135,4 @@ const printPostInstallMessages = (moduleNames, { isTTY = Boolean(process.stdout. return printed; }; -export { readPostInstall, printPostInstallMessages, normalize, stripUnsafe }; +export { readPostInstall, printPostInstallMessages, normalize, stripUnsafe, safeReadFile }; diff --git a/lib/modules/version.js b/lib/modules/version.js index 5a6657d2..97bce8a8 100644 --- a/lib/modules/version.js +++ b/lib/modules/version.js @@ -6,10 +6,9 @@ import files from '../files.js'; import logger from '../logger.js'; import report from '../logger/report.js'; import { moduleConfig } from '../modules.js'; -import { POS_MODULE_FILE as moduleManifestFileName } from './paths.js'; +import { POS_MODULE_FILE as moduleManifestFileName, TEMPLATE_VALUES_FILE } from './paths.js'; const BUMP_TYPES = ['major', 'minor', 'patch']; -const TEMPLATE_VALUES_FILE = 'template-values.json'; const templateValuesPath = (moduleName) => path.join('modules', moduleName, TEMPLATE_VALUES_FILE); diff --git a/package.json b/package.json index 6435a538..1353d14d 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,12 @@ "#test/*": "./test/*" }, "scripts": { - "pretest": "npm install --prefix test/fixtures/yeoman --silent", + "pretest": "npm install --prefix test/fixtures/yeoman --silent && npm install --prefix test/fixtures/yeoman/custom --silent", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:coverage:watch": "vitest --coverage", - "pretest:unit": "npm install --prefix test/fixtures/yeoman --silent", + "pretest:unit": "npm install --prefix test/fixtures/yeoman --silent && npm install --prefix test/fixtures/yeoman/custom --silent", "test:unit": "vitest run test/unit", "test:integration": "vitest run test/integration", "test:mcp-min": "vitest run mcp-min/__tests__", diff --git a/test/fixtures/yeoman/custom/package-lock.json b/test/fixtures/yeoman/custom/package-lock.json index 14975456..3a81df6c 100644 --- a/test/fixtures/yeoman/custom/package-lock.json +++ b/test/fixtures/yeoman/custom/package-lock.json @@ -96,7 +96,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -324,7 +323,8 @@ "version": "1.20.4", "resolved": "https://registry.npmjs.org/@types/expect/-/expect-1.20.4.tgz", "integrity": "sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/lodash": { "version": "4.17.23", @@ -346,7 +346,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -362,6 +361,7 @@ "resolved": "https://registry.npmjs.org/@types/vinyl/-/vinyl-2.0.12.tgz", "integrity": "sha512-Sr2fYMBUVGYq8kj3UthXFAu5UN6ZW+rYr4NACjZQJvHvj+c8lYv0CahmZ2P/r7iUkN44gGUBwqxZkrKXYPb7cw==", "license": "MIT", + "peer": true, "dependencies": { "@types/expect": "^1.20.4", "@types/node": "*" @@ -746,6 +746,7 @@ "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-5.0.0.tgz", "integrity": "sha512-WdHo4ejd2cG2Dl+sLkW79SctU7mUQDfr4s1i26ffOZRs5mgv+BRttIM9gwcq0rDbemo0KlpVPaa3LBVLqPXzcQ==", "license": "MIT", + "peer": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -924,7 +925,8 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/isbinaryfile": { "version": "5.0.3", @@ -1033,7 +1035,6 @@ "resolved": "https://registry.npmjs.org/mem-fs-editor/-/mem-fs-editor-11.1.4.tgz", "integrity": "sha512-Z4QX14Ev6eOVTuVSayS5rdiOua6C3gHcFw+n9Qc7WiaVTbC+H8b99c32MYGmbQN9UFHJeI/p3lf3LAxiIzwEmA==", "license": "MIT", - "peer": true, "dependencies": { "@types/ejs": "^3.1.4", "@types/node": ">=18", @@ -1584,6 +1585,7 @@ "resolved": "https://registry.npmjs.org/strip-bom-buf/-/strip-bom-buf-3.0.1.tgz", "integrity": "sha512-iJaWw2WroigLHzQysdc5WWeUc99p7ea7AEgB6JkY8CMyiO1yTVAA1gIlJJgORElUIR+lcZJkNl1OGChMhvc2Cw==", "license": "MIT", + "peer": true, "dependencies": { "is-utf8": "^0.2.1" }, @@ -1599,6 +1601,7 @@ "resolved": "https://registry.npmjs.org/strip-bom-stream/-/strip-bom-stream-5.0.0.tgz", "integrity": "sha512-Yo472mU+3smhzqeKlIxClre4s4pwtYZEvDNQvY/sJpnChdaxmKuwU28UVx/v1ORKNMxkmj1GBuvxJQyBk6wYMQ==", "license": "MIT", + "peer": true, "dependencies": { "first-chunk-stream": "^5.0.0", "strip-bom-buf": "^3.0.0" @@ -1760,6 +1763,7 @@ "resolved": "https://registry.npmjs.org/vinyl-file/-/vinyl-file-5.0.0.tgz", "integrity": "sha512-MvkPF/yA1EX7c6p+juVIvp9+Lxp70YUfNKzEWeHMKpUNVSnTZh2coaOqLxI0pmOe2V9nB+OkgFaMDkodaJUyGw==", "license": "MIT", + "peer": true, "dependencies": { "@types/vinyl": "^2.0.7", "strip-bom-buf": "^3.0.1", diff --git a/test/fixtures/yeoman/package-lock.json b/test/fixtures/yeoman/package-lock.json index fcf90d3d..64a7f74e 100644 --- a/test/fixtures/yeoman/package-lock.json +++ b/test/fixtures/yeoman/package-lock.json @@ -98,7 +98,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -326,7 +325,8 @@ "version": "1.20.4", "resolved": "https://registry.npmjs.org/@types/expect/-/expect-1.20.4.tgz", "integrity": "sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/lodash": { "version": "4.17.23", @@ -348,7 +348,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -364,6 +363,7 @@ "resolved": "https://registry.npmjs.org/@types/vinyl/-/vinyl-2.0.12.tgz", "integrity": "sha512-Sr2fYMBUVGYq8kj3UthXFAu5UN6ZW+rYr4NACjZQJvHvj+c8lYv0CahmZ2P/r7iUkN44gGUBwqxZkrKXYPb7cw==", "license": "MIT", + "peer": true, "dependencies": { "@types/expect": "^1.20.4", "@types/node": "*" @@ -748,6 +748,7 @@ "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-5.0.0.tgz", "integrity": "sha512-WdHo4ejd2cG2Dl+sLkW79SctU7mUQDfr4s1i26ffOZRs5mgv+BRttIM9gwcq0rDbemo0KlpVPaa3LBVLqPXzcQ==", "license": "MIT", + "peer": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -926,7 +927,8 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/isbinaryfile": { "version": "5.0.3", @@ -1041,7 +1043,6 @@ "resolved": "https://registry.npmjs.org/mem-fs-editor/-/mem-fs-editor-11.1.4.tgz", "integrity": "sha512-Z4QX14Ev6eOVTuVSayS5rdiOua6C3gHcFw+n9Qc7WiaVTbC+H8b99c32MYGmbQN9UFHJeI/p3lf3LAxiIzwEmA==", "license": "MIT", - "peer": true, "dependencies": { "@types/ejs": "^3.1.4", "@types/node": ">=18", @@ -1601,6 +1602,7 @@ "resolved": "https://registry.npmjs.org/strip-bom-buf/-/strip-bom-buf-3.0.1.tgz", "integrity": "sha512-iJaWw2WroigLHzQysdc5WWeUc99p7ea7AEgB6JkY8CMyiO1yTVAA1gIlJJgORElUIR+lcZJkNl1OGChMhvc2Cw==", "license": "MIT", + "peer": true, "dependencies": { "is-utf8": "^0.2.1" }, @@ -1616,6 +1618,7 @@ "resolved": "https://registry.npmjs.org/strip-bom-stream/-/strip-bom-stream-5.0.0.tgz", "integrity": "sha512-Yo472mU+3smhzqeKlIxClre4s4pwtYZEvDNQvY/sJpnChdaxmKuwU28UVx/v1ORKNMxkmj1GBuvxJQyBk6wYMQ==", "license": "MIT", + "peer": true, "dependencies": { "first-chunk-stream": "^5.0.0", "strip-bom-buf": "^3.0.0" @@ -1777,6 +1780,7 @@ "resolved": "https://registry.npmjs.org/vinyl-file/-/vinyl-file-5.0.0.tgz", "integrity": "sha512-MvkPF/yA1EX7c6p+juVIvp9+Lxp70YUfNKzEWeHMKpUNVSnTZh2coaOqLxI0pmOe2V9nB+OkgFaMDkodaJUyGw==", "license": "MIT", + "peer": true, "dependencies": { "@types/vinyl": "^2.0.7", "strip-bom-buf": "^3.0.1", diff --git a/test/unit/downloadModule.test.js b/test/unit/downloadModule.test.js index 5c2f732b..5e5c90b9 100644 --- a/test/unit/downloadModule.test.js +++ b/test/unit/downloadModule.test.js @@ -1,7 +1,13 @@ import { describe, test, expect, beforeEach, vi } from 'vitest'; import fs from 'fs'; import path from 'path'; -import { modulesToDownload, downloadModule, downloadAllModules } from '#lib/modules/downloadModule.js'; +import { + modulesToDownload, + modulesNotOnDisk, + readInstalledVersion, + downloadModule, + downloadAllModules, +} from '#lib/modules/downloadModule.js'; import { withTmpDir } from '#test/utils/withTmpDir.js'; vi.mock('#lib/portal.js', () => ({ @@ -16,8 +22,94 @@ vi.mock('#lib/unzip.js', () => ({ unzip: vi.fn() })); -// modulesToDownload checks process.cwd()/modules/ for directory existence. -// Tests use a temporary directory to control what's "on disk" without side effects. +// Simulates a previously-downloaded module by writing its manifest with a +// `version` field, mirroring what unzip actually leaves on disk. Defaults to +// pos-module.json (the current convention); pass file: 'template-values.json' +// to simulate a legacy-format module (version only in template-values.json, +// no pos-module.json at all — how many currently-published registry modules, +// e.g. real "core" releases, are actually laid out on disk today). +const writeInstalledManifest = (name, version, { file = 'pos-module.json', extra = {} } = {}) => { + const dir = path.join(process.cwd(), 'modules', name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, file), JSON.stringify({ machine_name: name, version, ...extra }, null, 2)); +}; + +const installModuleOnDisk = (name, version, extra) => writeInstalledManifest(name, version, { extra }); +const installLegacyModuleOnDisk = (name, version, extra) => + writeInstalledManifest(name, version, { file: 'template-values.json', extra }); + +// readInstalledVersion reads modules//pos-module.json's `version` field, +// falling back to modules//template-values.json for legacy modules. +describe('readInstalledVersion', () => { + withTmpDir(); + + test('returns null when the module directory does not exist', () => { + expect(readInstalledVersion('core')).toBeNull(); + }); + + test('returns null when the directory exists but neither manifest file is present', () => { + fs.mkdirSync(path.join(process.cwd(), 'modules', 'core'), { recursive: true }); + expect(readInstalledVersion('core')).toBeNull(); + }); + + test('returns null when pos-module.json exists but is not valid JSON, and no fallback exists', () => { + fs.mkdirSync(path.join(process.cwd(), 'modules', 'core'), { recursive: true }); + fs.writeFileSync(path.join(process.cwd(), 'modules', 'core', 'pos-module.json'), '{ not json'); + expect(readInstalledVersion('core')).toBeNull(); + }); + + test('returns null when pos-module.json has no version field and no fallback exists', () => { + installModuleOnDisk('core', undefined); + // installModuleOnDisk writes version: undefined, which JSON.stringify drops entirely + expect(readInstalledVersion('core')).toBeNull(); + }); + + test('returns the version recorded in pos-module.json', () => { + installModuleOnDisk('core', '2.0.6'); + expect(readInstalledVersion('core')).toBe('2.0.6'); + }); + + // Regression test: real published modules on the registry (e.g. core@1.5.5) + // predate the pos-module.json convention and only ship template-values.json. + test('falls back to template-values.json when pos-module.json does not exist', () => { + installLegacyModuleOnDisk('core', '1.5.5'); + expect(readInstalledVersion('core')).toBe('1.5.5'); + }); + + test('falls back to template-values.json when pos-module.json has no version field', () => { + const dir = path.join(process.cwd(), 'modules', 'core'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'pos-module.json'), JSON.stringify({ machine_name: 'core' })); + fs.writeFileSync(path.join(dir, 'template-values.json'), JSON.stringify({ version: '1.5.5' })); + expect(readInstalledVersion('core')).toBe('1.5.5'); + }); + + test('prefers pos-module.json version over template-values.json when both are present', () => { + installModuleOnDisk('core', '2.0.6'); + fs.writeFileSync( + path.join(process.cwd(), 'modules', 'core', 'template-values.json'), + JSON.stringify({ version: '1.5.5' }) + ); + expect(readInstalledVersion('core')).toBe('2.0.6'); + }); + + test('returns null when template-values.json exists but is not valid JSON', () => { + const dir = path.join(process.cwd(), 'modules', 'core'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'template-values.json'), '{ not json'); + expect(readInstalledVersion('core')).toBeNull(); + }); + + test('returns null when template-values.json exists but has no version field', () => { + const dir = path.join(process.cwd(), 'modules', 'core'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'template-values.json'), JSON.stringify({ machine_name: 'core' })); + expect(readInstalledVersion('core')).toBeNull(); + }); +}); + +// modulesToDownload checks the installed version of each module on disk against +// the newly resolved version, not just directory existence. describe('modulesToDownload', () => { withTmpDir(); @@ -31,6 +123,7 @@ describe('modulesToDownload', () => { }); test('includes a module when its version changed', () => { + installModuleOnDisk('core', '2.0.6'); const result = modulesToDownload({ core: '2.0.7' }, { core: '2.0.6' }); expect(result).toEqual({ core: '2.0.7' }); }); @@ -41,42 +134,112 @@ describe('modulesToDownload', () => { expect(result).toEqual({ core: '2.0.6' }); }); - test('skips a module when version matches and directory exists on disk', () => { - fs.mkdirSync(path.join(process.cwd(), 'modules', 'core'), { recursive: true }); + test('skips a module when version matches and the installed module is at that version', () => { + installModuleOnDisk('core', '2.0.6'); const result = modulesToDownload({ core: '2.0.6' }, { core: '2.0.6' }); expect(result).toEqual({}); }); - test('handles a mix: skips up-to-date, includes changed or missing', () => { - // core: up-to-date and on disk → skip + // Regression test for the bug where `pos-cli modules install` silently did nothing + // even though a module's lock version had moved on: the module directory existed + // (from an older install) but its content was still at the old version. + test('includes a module when the lock version is unchanged but the on-disk module is stale', () => { + installModuleOnDisk('chat', '1.3.4'); + + const result = modulesToDownload({ chat: '2.0.2' }, { chat: '2.0.2' }); + expect(result).toEqual({ chat: '2.0.2' }); + }); + + test('handles a mix: skips up-to-date, includes changed, missing, or stale', () => { + // core: up-to-date and installed at the right version → skip // user: version bumped → download - // tests: version matches but directory missing → download - fs.mkdirSync(path.join(process.cwd(), 'modules', 'core'), { recursive: true }); + // tests: lock version matches but directory missing → download + // chat: lock version matches but installed version is stale → download + installModuleOnDisk('core', '2.0.6'); + installModuleOnDisk('chat', '1.3.4'); - const locked = { core: '2.0.6', user: '5.1.3', tests: '1.2.0' }; - const previous = { core: '2.0.6', user: '5.1.2', tests: '1.2.0' }; + const locked = { core: '2.0.6', user: '5.1.3', tests: '1.2.0', chat: '2.0.2' }; + const previous = { core: '2.0.6', user: '5.1.2', tests: '1.2.0', chat: '2.0.2' }; const result = modulesToDownload(locked, previous); - expect(result).toEqual({ user: '5.1.3', tests: '1.2.0' }); + expect(result).toEqual({ user: '5.1.3', tests: '1.2.0', chat: '2.0.2' }); }); test('includes all modules when previous lock is empty (first install)', () => { - fs.mkdirSync(path.join(process.cwd(), 'modules', 'core'), { recursive: true }); + installModuleOnDisk('core', '2.0.6'); - // Even though core directory exists, no previous lock → treat as fresh install + // Even though core is installed at the target version, no previous lock → treat as fresh install const result = modulesToDownload({ core: '2.0.6', user: '5.1.2' }, {}); expect(result).toEqual({ core: '2.0.6', user: '5.1.2' }); }); - test('skips all modules when every version matches and every directory exists', () => { - fs.mkdirSync(path.join(process.cwd(), 'modules', 'core'), { recursive: true }); - fs.mkdirSync(path.join(process.cwd(), 'modules', 'user'), { recursive: true }); + test('skips all modules when every version matches and every module is installed correctly', () => { + installModuleOnDisk('core', '2.0.6'); + installModuleOnDisk('user', '5.1.2'); const modules = { core: '2.0.6', user: '5.1.2' }; const result = modulesToDownload(modules, modules); expect(result).toEqual({}); }); + + // Regression test: a transitive dependency already on disk in the legacy + // template-values.json-only format (as real published modules like core are + // laid out today) must be recognized as installed and skipped, not re-downloaded. + test('skips a legacy-format module (version only in template-values.json) at the target version', () => { + installLegacyModuleOnDisk('core', '1.5.5'); + + const result = modulesToDownload({ core: '1.5.5' }, { core: '1.5.5' }); + expect(result).toEqual({}); + }); +}); + +// modulesNotOnDisk checks the installed version of each module against the target +// version — used by frozenInstall (--frozen CI, and smartInstall's fast path) where +// there is no previous lock to diff against, only the current disk state. +describe('modulesNotOnDisk', () => { + withTmpDir(); + + test('returns empty object when the module set is empty', () => { + expect(modulesNotOnDisk({})).toEqual({}); + }); + + test('includes a module missing from disk entirely', () => { + const result = modulesNotOnDisk({ core: '2.0.6' }); + expect(result).toEqual({ core: '2.0.6' }); + }); + + test('skips a module installed at the target version', () => { + installModuleOnDisk('core', '2.0.6'); + expect(modulesNotOnDisk({ core: '2.0.6' })).toEqual({}); + }); + + // Regression test for the reported bug: modules/chat existed on disk at 1.3.4 + // while pos-module.lock.json recorded 2.0.2 — `pos-cli modules install` must + // still redownload it instead of treating "directory exists" as "up to date". + test('includes a module whose installed version does not match the target version', () => { + installModuleOnDisk('chat', '1.3.4'); + + const result = modulesNotOnDisk({ chat: '2.0.2' }); + expect(result).toEqual({ chat: '2.0.2' }); + }); + + test('handles a mix of up-to-date, stale, and missing modules', () => { + installModuleOnDisk('core', '2.0.6'); + installModuleOnDisk('chat', '1.3.4'); + + const result = modulesNotOnDisk({ core: '2.0.6', chat: '2.0.2', user: '5.1.2' }); + expect(result).toEqual({ chat: '2.0.2', user: '5.1.2' }); + }); + + // Regression test mirroring the real integration scenario: a transitive dep + // (core) already installed in the legacy template-values.json-only format + // must be recognized as up-to-date under --frozen / smartInstall's fast path. + test('skips a legacy-format module (version only in template-values.json) at the target version', () => { + installLegacyModuleOnDisk('core', '1.5.5'); + + expect(modulesNotOnDisk({ core: '1.5.5' })).toEqual({}); + }); }); // downloadModule downloads a single module archive and extracts it.