11/**
2- * Tests for plugin management: install, uninstall, list.
2+ * Tests for plugin management: install, uninstall, list, and lock file support .
33 */
44
55import { describe , it , expect , beforeEach , afterEach , vi } from 'vitest' ;
66import * as fs from 'node:fs' ;
77import * as path from 'node:path' ;
88import { PLUGINS_DIR } from './discovery.js' ;
9+ import type { LockEntry } from './plugin.js' ;
910import * as pluginModule from './plugin.js' ;
1011
1112const {
13+ LOCK_FILE ,
14+ _getCommitHash,
1215 listPlugins,
16+ _readLockFile,
1317 uninstallPlugin,
1418 updatePlugin,
1519 _parseSource,
1620 _updateAllPlugins,
1721 _validatePluginStructure,
22+ _writeLockFile,
1823} = pluginModule ;
1924
2025describe ( 'parseSource' , ( ) => {
@@ -111,6 +116,70 @@ describe('validatePluginStructure', () => {
111116 } ) ;
112117} ) ;
113118
119+ describe ( 'lock file' , ( ) => {
120+ const backupPath = `${ LOCK_FILE } .test-backup` ;
121+ let hadOriginal = false ;
122+
123+ beforeEach ( ( ) => {
124+ hadOriginal = fs . existsSync ( LOCK_FILE ) ;
125+ if ( hadOriginal ) {
126+ fs . mkdirSync ( path . dirname ( backupPath ) , { recursive : true } ) ;
127+ fs . copyFileSync ( LOCK_FILE , backupPath ) ;
128+ }
129+ } ) ;
130+
131+ afterEach ( ( ) => {
132+ if ( hadOriginal ) {
133+ fs . copyFileSync ( backupPath , LOCK_FILE ) ;
134+ fs . unlinkSync ( backupPath ) ;
135+ return ;
136+ }
137+ try { fs . unlinkSync ( LOCK_FILE ) ; } catch { }
138+ } ) ;
139+
140+ it ( 'reads empty lock when file does not exist' , ( ) => {
141+ try { fs . unlinkSync ( LOCK_FILE ) ; } catch { }
142+ expect ( _readLockFile ( ) ) . toEqual ( { } ) ;
143+ } ) ;
144+
145+ it ( 'round-trips lock entries' , ( ) => {
146+ const entries : Record < string , LockEntry > = {
147+ 'test-plugin' : {
148+ source : 'https://github.com/user/repo.git' ,
149+ commitHash : 'abc1234567890def' ,
150+ installedAt : '2025-01-01T00:00:00.000Z' ,
151+ } ,
152+ 'another-plugin' : {
153+ source : 'https://github.com/user/another.git' ,
154+ commitHash : 'def4567890123abc' ,
155+ installedAt : '2025-02-01T00:00:00.000Z' ,
156+ updatedAt : '2025-03-01T00:00:00.000Z' ,
157+ } ,
158+ } ;
159+
160+ _writeLockFile ( entries ) ;
161+ expect ( _readLockFile ( ) ) . toEqual ( entries ) ;
162+ } ) ;
163+
164+ it ( 'handles malformed lock file gracefully' , ( ) => {
165+ fs . mkdirSync ( path . dirname ( LOCK_FILE ) , { recursive : true } ) ;
166+ fs . writeFileSync ( LOCK_FILE , 'not valid json' ) ;
167+ expect ( _readLockFile ( ) ) . toEqual ( { } ) ;
168+ } ) ;
169+ } ) ;
170+
171+ describe ( 'getCommitHash' , ( ) => {
172+ it ( 'returns a hash for a git repo' , ( ) => {
173+ const hash = _getCommitHash ( process . cwd ( ) ) ;
174+ expect ( hash ) . toBeDefined ( ) ;
175+ expect ( hash ) . toMatch ( / ^ [ 0 - 9 a - f ] { 40 } $ / ) ;
176+ } ) ;
177+
178+ it ( 'returns undefined for non-git directory' , ( ) => {
179+ expect ( _getCommitHash ( '/tmp' ) ) . toBeUndefined ( ) ;
180+ } ) ;
181+ } ) ;
182+
114183describe ( 'listPlugins' , ( ) => {
115184 const testDir = path . join ( PLUGINS_DIR , '__test-list-plugin__' ) ;
116185
@@ -128,6 +197,28 @@ describe('listPlugins', () => {
128197 expect ( found ! . commands ) . toContain ( 'hello' ) ;
129198 } ) ;
130199
200+ it ( 'includes version metadata from the lock file' , ( ) => {
201+ fs . mkdirSync ( testDir , { recursive : true } ) ;
202+ fs . writeFileSync ( path . join ( testDir , 'hello.yaml' ) , 'site: test\nname: hello\n' ) ;
203+
204+ const lock = _readLockFile ( ) ;
205+ lock [ '__test-list-plugin__' ] = {
206+ source : 'https://github.com/user/repo.git' ,
207+ commitHash : 'abcdef1234567890abcdef1234567890abcdef12' ,
208+ installedAt : '2025-01-01T00:00:00.000Z' ,
209+ } ;
210+ _writeLockFile ( lock ) ;
211+
212+ const plugins = listPlugins ( ) ;
213+ const found = plugins . find ( p => p . name === '__test-list-plugin__' ) ;
214+ expect ( found ) . toBeDefined ( ) ;
215+ expect ( found ! . version ) . toBe ( 'abcdef1' ) ;
216+ expect ( found ! . installedAt ) . toBe ( '2025-01-01T00:00:00.000Z' ) ;
217+
218+ delete lock [ '__test-list-plugin__' ] ;
219+ _writeLockFile ( lock ) ;
220+ } ) ;
221+
131222 it ( 'returns empty array when no plugins dir' , ( ) => {
132223 // listPlugins should handle missing dir gracefully
133224 const plugins = listPlugins ( ) ;
@@ -150,6 +241,22 @@ describe('uninstallPlugin', () => {
150241 expect ( fs . existsSync ( testDir ) ) . toBe ( false ) ;
151242 } ) ;
152243
244+ it ( 'removes lock entry on uninstall' , ( ) => {
245+ fs . mkdirSync ( testDir , { recursive : true } ) ;
246+ fs . writeFileSync ( path . join ( testDir , 'test.yaml' ) , 'site: test' ) ;
247+
248+ const lock = _readLockFile ( ) ;
249+ lock [ '__test-uninstall__' ] = {
250+ source : 'https://github.com/user/repo.git' ,
251+ commitHash : 'abc123' ,
252+ installedAt : '2025-01-01T00:00:00.000Z' ,
253+ } ;
254+ _writeLockFile ( lock ) ;
255+
256+ uninstallPlugin ( '__test-uninstall__' ) ;
257+ expect ( _readLockFile ( ) [ '__test-uninstall__' ] ) . toBeUndefined ( ) ;
258+ } ) ;
259+
153260 it ( 'throws for non-existent plugin' , ( ) => {
154261 expect ( ( ) => uninstallPlugin ( '__nonexistent__' ) ) . toThrow ( 'not installed' ) ;
155262 } ) ;
@@ -163,7 +270,13 @@ describe('updatePlugin', () => {
163270
164271vi . mock ( 'node:child_process' , ( ) => {
165272 return {
166- execFileSync : vi . fn ( ( _cmd , _args , opts ) => {
273+ execFileSync : vi . fn ( ( _cmd , args , opts ) => {
274+ if ( Array . isArray ( args ) && args [ 0 ] === 'rev-parse' && args [ 1 ] === 'HEAD' ) {
275+ if ( opts ?. cwd === '/tmp' ) {
276+ throw new Error ( 'not a git repository' ) ;
277+ }
278+ return '1234567890abcdef1234567890abcdef12345678\n' ;
279+ }
167280 if ( opts && opts . cwd && String ( opts . cwd ) . endsWith ( 'plugin-b' ) ) {
168281 throw new Error ( 'Network error' ) ;
169282 }
0 commit comments