11import assert from "node:assert/strict" ;
2- import { mkdtemp , mkdir , rm , writeFile } from "node:fs/promises" ;
2+ import { mkdtemp , mkdir , rm , symlink , writeFile } from "node:fs/promises" ;
33import { tmpdir } from "node:os" ;
44import { join } from "node:path" ;
55import test from "node:test" ;
6+ import type { ExtensionAPI , ExtensionCommandContext , ExtensionContext } from "@earendil-works/pi-coding-agent" ;
67import { buildContextInjection , collectSkillPaths , findFiles , scanDirContext } from "../extensions/add-dir-helpers.ts" ;
8+ import addDirExtension from "../extensions/add-dir.ts" ;
9+
10+ interface RegisteredTool {
11+ name : string ;
12+ execute : ( ...args : any [ ] ) => Promise < any > ;
13+ }
14+
15+ interface RegisteredCommand {
16+ handler : ( args : string , ctx : ExtensionCommandContext ) => Promise < void > ;
17+ }
18+
19+ function loadExtension ( ) : {
20+ commands : Map < string , RegisteredCommand > ;
21+ handlers : Map < string , ( event : unknown , ctx : ExtensionContext ) => unknown > ;
22+ sentMessages : Array < { content : string ; expandPromptTemplates ?: boolean } > ;
23+ tools : Map < string , RegisteredTool > ;
24+ } {
25+ const commands = new Map < string , RegisteredCommand > ( ) ;
26+ const handlers = new Map < string , ( event : unknown , ctx : ExtensionContext ) => unknown > ( ) ;
27+ const sentMessages : Array < { content : string ; expandPromptTemplates ?: boolean } > = [ ] ;
28+ const tools = new Map < string , RegisteredTool > ( ) ;
29+ addDirExtension ( {
30+ on ( event : string , handler : ( event : unknown , ctx : ExtensionContext ) => unknown ) {
31+ handlers . set ( event , handler ) ;
32+ } ,
33+ registerCommand ( name : string , command : RegisteredCommand ) {
34+ commands . set ( name , command ) ;
35+ } ,
36+ registerTool ( tool : RegisteredTool ) {
37+ tools . set ( tool . name , tool ) ;
38+ } ,
39+ appendEntry ( ) { } ,
40+ sendUserMessage ( content : string , options ?: { expandPromptTemplates ?: boolean } ) {
41+ sentMessages . push ( { content, expandPromptTemplates : options ?. expandPromptTemplates } ) ;
42+ } ,
43+ } as unknown as ExtensionAPI ) ;
44+ return { commands, handlers, sentMessages, tools } ;
45+ }
46+
47+ function extensionContext ( cwd : string , getBranch : ( ) => unknown [ ] , reload = async ( ) => { } ) : ExtensionContext {
48+ return {
49+ cwd,
50+ hasUI : false ,
51+ sessionManager : { getBranch } ,
52+ ui : { setWidget ( ) { } } ,
53+ reload,
54+ } as unknown as ExtensionContext ;
55+ }
756
857test ( "registers external skills without duplicating Pi's skill prompt" , async ( ) => {
958 const dir = await mkdtemp ( join ( tmpdir ( ) , "pi-add-dir-" ) ) ;
@@ -24,8 +73,110 @@ test("registers external skills without duplicating Pi's skill prompt", async ()
2473 }
2574} ) ;
2675
76+ test ( "finds files recursively while skipping dependency and Git trees" , async ( ) => {
77+ const dir = await mkdtemp ( join ( tmpdir ( ) , "pi-add-dir-" ) ) ;
78+ try {
79+ const matches = [ join ( dir , "src" , "main.ts" ) , join ( dir , ".hidden" , "config.ts" ) ] ;
80+ for ( const file of [ ...matches , join ( dir , "node_modules" , "ignored.ts" ) , join ( dir , ".git" , "ignored.ts" ) ] ) {
81+ await mkdir ( join ( file , ".." ) , { recursive : true } ) ;
82+ await writeFile ( file , "" ) ;
83+ }
84+
85+ assert . deepEqual ( ( await findFiles ( dir , "*.ts" , 10 ) ) . sort ( ) , matches . sort ( ) ) ;
86+ } finally {
87+ await rm ( dir , { recursive : true , force : true } ) ;
88+ }
89+ } ) ;
90+
91+ test ( "ignores symbolic links and their descendants when finding files" , async ( ) => {
92+ const root = await mkdtemp ( join ( tmpdir ( ) , "pi-add-dir-" ) ) ;
93+ const target = await mkdtemp ( join ( tmpdir ( ) , "pi-add-dir-" ) ) ;
94+ try {
95+ const included = join ( root , "src" , "main.ts" ) ;
96+ await mkdir ( join ( included , ".." ) , { recursive : true } ) ;
97+ await mkdir ( join ( target , "nested" ) , { recursive : true } ) ;
98+ await writeFile ( included , "" ) ;
99+ await writeFile ( join ( target , "nested" , "linked.ts" ) , "" ) ;
100+ await symlink ( target , join ( root , "linked" ) , process . platform === "win32" ? "junction" : "dir" ) ;
101+
102+ assert . deepEqual ( await findFiles ( root , "*.ts" , 10 ) , [ included ] ) ;
103+ } finally {
104+ await rm ( root , { recursive : true , force : true } ) ;
105+ await rm ( target , { recursive : true , force : true } ) ;
106+ }
107+ } ) ;
108+
27109test ( "returns no results when an external directory disappears" , async ( ) => {
28110 const dir = await mkdtemp ( join ( tmpdir ( ) , "pi-add-dir-" ) ) ;
29111 await rm ( dir , { recursive : true , force : true } ) ;
30112 assert . deepEqual ( await findFiles ( dir , "*.ts" , 1 ) , [ ] ) ;
31113} ) ;
114+
115+ test ( "rejects an ancestor of the current working directory" , async ( ) => {
116+ const root = await mkdtemp ( join ( tmpdir ( ) , "pi-add-dir-" ) ) ;
117+ try {
118+ const cwd = join ( root , "project" ) ;
119+ await mkdir ( cwd ) ;
120+ const { tools } = loadExtension ( ) ;
121+ const addDirectory = tools . get ( "add_directory" ) ! ;
122+
123+ await assert . rejects (
124+ addDirectory . execute ( "call" , { path : root } , undefined , undefined , extensionContext ( cwd , ( ) => [ ] ) ) ,
125+ / o v e r l a p s c u r r e n t w o r k i n g d i r e c t o r y s c o p e / ,
126+ ) ;
127+ } finally {
128+ await rm ( root , { recursive : true , force : true } ) ;
129+ }
130+ } ) ;
131+
132+ test ( "queues a resource reload when tree navigation changes added directories" , async ( ) => {
133+ const external = await mkdtemp ( join ( tmpdir ( ) , "pi-add-dir-" ) ) ;
134+ try {
135+ let branch : unknown [ ] = [
136+ {
137+ type : "custom" ,
138+ customType : "add-dir:state" ,
139+ data : { dirs : [ { absolutePath : external , label : "external" } ] } ,
140+ } ,
141+ ] ;
142+ let reloads = 0 ;
143+ const { commands, handlers, sentMessages } = loadExtension ( ) ;
144+ const ctx = extensionContext ( process . cwd ( ) , ( ) => branch , async ( ) => {
145+ reloads += 1 ;
146+ } ) ;
147+
148+ await handlers . get ( "session_start" ) ! ( { } , ctx ) ;
149+ await handlers . get ( "session_tree" ) ! ( { } , ctx ) ;
150+ assert . deepEqual ( sentMessages , [ ] ) ;
151+
152+ branch = [ ] ;
153+ await handlers . get ( "session_tree" ) ! ( { } , ctx ) ;
154+ assert . deepEqual ( sentMessages , [ { content : "/dir-reload" , expandPromptTemplates : true } ] ) ;
155+ await commands . get ( "dir-reload" ) ! . handler ( "" , ctx as ExtensionCommandContext ) ;
156+ assert . equal ( reloads , 1 ) ;
157+ } finally {
158+ await rm ( external , { recursive : true , force : true } ) ;
159+ }
160+ } ) ;
161+
162+ test ( "reports non-missing filesystem failures" , { skip : process . platform === "win32" } , async ( ) => {
163+ const root = await mkdtemp ( join ( tmpdir ( ) , "pi-add-dir-" ) ) ;
164+ try {
165+ const loop = join ( root , "loop" ) ;
166+ await symlink ( "loop" , loop ) ;
167+ const { tools } = loadExtension ( ) ;
168+
169+ await assert . rejects (
170+ tools . get ( "add_directory" ) ! . execute (
171+ "call" ,
172+ { path : loop } ,
173+ undefined ,
174+ undefined ,
175+ extensionContext ( process . cwd ( ) , ( ) => [ ] ) ,
176+ ) ,
177+ / C a n n o t a c c e s s d i r e c t o r y : .* (?: E L O O P | t o o m a n y s y m b o l i c l i n k s ) / i,
178+ ) ;
179+ } finally {
180+ await rm ( root , { recursive : true , force : true } ) ;
181+ }
182+ } ) ;
0 commit comments