|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * resolveConsolePath() hardening — stale out-of-workspace installs. |
| 5 | + * |
| 6 | + * Node module resolution from the consumer cwd climbs `node_modules` |
| 7 | + * directories all the way up the filesystem. A stray |
| 8 | + * `~/node_modules/@objectstack/console` left behind by an old npm |
| 9 | + * experiment used to win over the version-locked bundle and serve a |
| 10 | + * stale Console (browser-side OBJUI-001 "Unknown component type"). |
| 11 | + * These tests pin the major-version guard that skips such candidates. |
| 12 | + */ |
| 13 | +import { describe, it, expect } from 'vitest'; |
| 14 | +import fs from 'fs'; |
| 15 | +import os from 'os'; |
| 16 | +import path from 'path'; |
| 17 | +import { |
| 18 | + resolveConsolePath, |
| 19 | + isConsoleVersionCompatible, |
| 20 | +} from '../src/utils/console.js'; |
| 21 | + |
| 22 | +const CLI_VERSION = '9.2.0'; |
| 23 | + |
| 24 | +function writeConsolePackage( |
| 25 | + dir: string, |
| 26 | + { name = '@objectstack/console', version, withDist = true }: { |
| 27 | + name?: string; |
| 28 | + version: string; |
| 29 | + withDist?: boolean; |
| 30 | + }, |
| 31 | +): string { |
| 32 | + const pkgDir = path.join(dir, 'node_modules', '@objectstack', 'console'); |
| 33 | + fs.mkdirSync(pkgDir, { recursive: true }); |
| 34 | + fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name, version })); |
| 35 | + if (withDist) { |
| 36 | + fs.mkdirSync(path.join(pkgDir, 'dist'), { recursive: true }); |
| 37 | + fs.writeFileSync(path.join(pkgDir, 'dist', 'index.html'), '<html></html>'); |
| 38 | + } |
| 39 | + return pkgDir; |
| 40 | +} |
| 41 | + |
| 42 | +/** Fresh sandbox: <tmp>/home/project is the cwd, <tmp>/home simulates $HOME. */ |
| 43 | +function makeSandbox(): { home: string; project: string } { |
| 44 | + // realpath: node's require.resolve returns symlink-resolved paths, and |
| 45 | + // macOS tmpdir lives behind the /var -> /private/var symlink. |
| 46 | + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'os-console-resolve-'))); |
| 47 | + const home = path.join(root, 'home'); |
| 48 | + const project = path.join(home, 'project'); |
| 49 | + fs.mkdirSync(project, { recursive: true }); |
| 50 | + fs.writeFileSync( |
| 51 | + path.join(project, 'package.json'), |
| 52 | + JSON.stringify({ name: 'consumer-app', version: '1.0.0' }), |
| 53 | + ); |
| 54 | + return { home, project }; |
| 55 | +} |
| 56 | + |
| 57 | +describe('isConsoleVersionCompatible', () => { |
| 58 | + it('accepts the same major', () => { |
| 59 | + expect(isConsoleVersionCompatible('9.2.0', '9.2.0')).toBe(true); |
| 60 | + expect(isConsoleVersionCompatible('9.0.1', '9.5.0')).toBe(true); |
| 61 | + expect(isConsoleVersionCompatible('9.3.0-beta.1', '9.2.0')).toBe(true); |
| 62 | + }); |
| 63 | + |
| 64 | + it('rejects a different major', () => { |
| 65 | + expect(isConsoleVersionCompatible('7.8.0', '9.2.0')).toBe(false); |
| 66 | + expect(isConsoleVersionCompatible('10.0.0', '9.2.0')).toBe(false); |
| 67 | + }); |
| 68 | + |
| 69 | + it('rejects missing or malformed versions', () => { |
| 70 | + expect(isConsoleVersionCompatible(undefined, '9.2.0')).toBe(false); |
| 71 | + expect(isConsoleVersionCompatible('', '9.2.0')).toBe(false); |
| 72 | + expect(isConsoleVersionCompatible('not-a-version', '9.2.0')).toBe(false); |
| 73 | + }); |
| 74 | +}); |
| 75 | + |
| 76 | +describe('resolveConsolePath version guard', () => { |
| 77 | + it('skips a stale major-mismatched install climbed to outside the project, with a warning', () => { |
| 78 | + const { home, project } = makeSandbox(); |
| 79 | + // The incident shape: ~/node_modules/@objectstack/console@7.8.0 with a |
| 80 | + // built dist, reachable from the project cwd by climbing node_modules. |
| 81 | + const stale = writeConsolePackage(home, { version: '7.8.0' }); |
| 82 | + |
| 83 | + const warnings: string[] = []; |
| 84 | + const result = resolveConsolePath({ |
| 85 | + cwd: project, |
| 86 | + cliVersion: CLI_VERSION, |
| 87 | + warn: (m) => warnings.push(m), |
| 88 | + }); |
| 89 | + |
| 90 | + expect(result).not.toBe(stale); |
| 91 | + expect(warnings.some((m) => m.includes('7.8.0') && m.includes(stale))).toBe(true); |
| 92 | + }); |
| 93 | + |
| 94 | + it('accepts a same-major install climbed to from the project cwd', () => { |
| 95 | + const { home, project } = makeSandbox(); |
| 96 | + const ok = writeConsolePackage(home, { version: '9.0.0' }); |
| 97 | + |
| 98 | + const warnings: string[] = []; |
| 99 | + const result = resolveConsolePath({ |
| 100 | + cwd: project, |
| 101 | + cliVersion: CLI_VERSION, |
| 102 | + warn: (m) => warnings.push(m), |
| 103 | + }); |
| 104 | + |
| 105 | + expect(result).toBe(ok); |
| 106 | + expect(warnings).toEqual([]); |
| 107 | + }); |
| 108 | + |
| 109 | + it('prefers a matching local install over a stale parent-directory one', () => { |
| 110 | + const { home, project } = makeSandbox(); |
| 111 | + writeConsolePackage(home, { version: '7.8.0' }); |
| 112 | + const local = writeConsolePackage(project, { version: '9.2.0' }); |
| 113 | + |
| 114 | + const result = resolveConsolePath({ |
| 115 | + cwd: project, |
| 116 | + cliVersion: CLI_VERSION, |
| 117 | + warn: () => {}, |
| 118 | + }); |
| 119 | + |
| 120 | + expect(result).toBe(local); |
| 121 | + }); |
| 122 | + |
| 123 | + it('skips an install whose package.json carries no version', () => { |
| 124 | + const { home, project } = makeSandbox(); |
| 125 | + const unversionedDir = path.join(home, 'node_modules', '@objectstack', 'console'); |
| 126 | + fs.mkdirSync(path.join(unversionedDir, 'dist'), { recursive: true }); |
| 127 | + fs.writeFileSync( |
| 128 | + path.join(unversionedDir, 'package.json'), |
| 129 | + JSON.stringify({ name: '@objectstack/console' }), |
| 130 | + ); |
| 131 | + fs.writeFileSync(path.join(unversionedDir, 'dist', 'index.html'), '<html></html>'); |
| 132 | + |
| 133 | + const warnings: string[] = []; |
| 134 | + const result = resolveConsolePath({ |
| 135 | + cwd: project, |
| 136 | + cliVersion: CLI_VERSION, |
| 137 | + warn: (m) => warnings.push(m), |
| 138 | + }); |
| 139 | + |
| 140 | + expect(result).not.toBe(unversionedDir); |
| 141 | + expect(warnings.some((m) => m.includes('unknown'))).toBe(true); |
| 142 | + }); |
| 143 | +}); |
0 commit comments