1818 *
1919 * Suite 2 — Articles (search-seed):
2020 * 5 articles with tsvector, pg_trgm, optional pgvector columns
21- * Tests: search subcommand, tsvector search, trgm fuzzy matching,
22- * composite fullTextSearch, search+pagination, pgvector (conditional)
23- *
24- * Suite 3 — Blueprint generation:
25- * Tests: generate-types with live _meta, graceful fallback without --meta
21+ * Tests: tsvector search, trgm fuzzy matching, composite fullTextSearch,
22+ * search+pagination, pgvector error handling, schema introspection
2623 */
2724
2825import path from 'path' ;
@@ -284,79 +281,95 @@ function setupAppstashContext(
284281 }
285282}
286283
284+ /**
285+ * Bootstrap script written to disk for the child process.
286+ * Requires the generated CLI commands and executes them in non-interactive mode.
287+ */
288+ const RUNNER_SCRIPT = `
289+ const { parseArgv, Inquirerer } = require('inquirerer');
290+ const { commands } = require('./cli/commands');
291+
292+ const argv = parseArgv(process.argv);
293+ const prompter = new Inquirerer({
294+ input: process.stdin,
295+ output: process.stdout,
296+ noTty: true,
297+ });
298+
299+ commands(argv, prompter, { noTty: true })
300+ .then(() => process.exit(0))
301+ .catch((e) => {
302+ console.error(e.message);
303+ process.exit(1);
304+ });
305+ ` . trimStart ( ) ;
306+
307+ /**
308+ * Run the compiled CLI as a child process.
309+ * Uses spawn (not execFileSync) so the Node.js event loop stays unblocked —
310+ * the GraphQL server in this process can respond to requests.
311+ */
312+ function runCli (
313+ distDir : string ,
314+ tmpHome : string ,
315+ ...args : string [ ]
316+ ) : Promise < string > {
317+ const runnerPath = path . join ( distDir , '_runner.js' ) ;
318+ if ( ! fs . existsSync ( runnerPath ) ) {
319+ fs . writeFileSync ( runnerPath , RUNNER_SCRIPT , 'utf-8' ) ;
320+ }
321+
322+ return new Promise < string > ( ( resolve , reject ) => {
323+ const child = spawn (
324+ process . execPath ,
325+ [ runnerPath , ...args ] ,
326+ {
327+ env : {
328+ ...process . env ,
329+ APPSTASH_BASE_DIR : tmpHome ,
330+ NODE_PATH : [
331+ distDir ,
332+ ...resolveNodePaths ( ) ,
333+ ] . join ( path . delimiter ) ,
334+ } ,
335+ stdio : [ 'pipe' , 'pipe' , 'pipe' ] ,
336+ } ,
337+ ) ;
338+
339+ let stdout = '' ;
340+ let stderr = '' ;
341+ child . stdout . on ( 'data' , ( chunk : Buffer ) => {
342+ stdout += chunk . toString ( ) ;
343+ } ) ;
344+ child . stderr . on ( 'data' , ( chunk : Buffer ) => {
345+ stderr += chunk . toString ( ) ;
346+ } ) ;
347+
348+ const timer = setTimeout ( ( ) => {
349+ child . kill ( ) ;
350+ reject ( new Error ( `CLI timed out after 30s.\nstdout: ${ stdout } \nstderr: ${ stderr } ` ) ) ;
351+ } , 30000 ) ;
352+
353+ child . on ( 'close' , ( code ) => {
354+ clearTimeout ( timer ) ;
355+ if ( code !== 0 ) {
356+ reject ( new Error ( `CLI exited with code ${ code } .\nstdout: ${ stdout } \nstderr: ${ stderr } ` ) ) ;
357+ } else {
358+ resolve ( stdout ) ;
359+ }
360+ } ) ;
361+
362+ child . stdin . end ( ) ;
363+ } ) ;
364+ }
365+
287366describe ( 'CLI E2E — generated CLI against real DB' , ( ) => {
288367 let server : ServerInfo ;
289368 let teardown : ( ) => Promise < void > ;
290369 let tmpDir : string ;
291370 let tmpHome : string ;
292371 let distDir : string ;
293372
294- /**
295- * Run the compiled CLI as a child process (async).
296- * Uses spawn instead of execFileSync so the Node.js event loop stays
297- * unblocked — the GraphQL server in this process can respond to requests.
298- */
299- function runCli ( ...args : string [ ] ) : Promise < string > {
300- const runnerPath = path . join ( distDir , '_runner.js' ) ;
301- if ( ! fs . existsSync ( runnerPath ) ) {
302- fs . writeFileSync (
303- runnerPath ,
304- [
305- "const { parseArgv, Inquirerer } = require('inquirerer');" ,
306- "const { commands } = require('./cli/commands');" ,
307- 'const argv = parseArgv(process.argv);' ,
308- 'const prompter = new Inquirerer({ input: process.stdin, output: process.stdout, noTty: true });' ,
309- "commands(argv, prompter, { noTty: true }).then(() => process.exit(0)).catch(e => { console.error(e.message); process.exit(1); });" ,
310- ] . join ( '\n' ) ,
311- 'utf-8' ,
312- ) ;
313- }
314-
315- return new Promise < string > ( ( resolve , reject ) => {
316- const child = spawn (
317- process . execPath ,
318- [ runnerPath , ...args ] ,
319- {
320- env : {
321- ...process . env ,
322- APPSTASH_BASE_DIR : tmpHome ,
323- NODE_PATH : [
324- distDir ,
325- ...resolveNodePaths ( ) ,
326- ] . join ( path . delimiter ) ,
327- } ,
328- stdio : [ 'pipe' , 'pipe' , 'pipe' ] ,
329- } ,
330- ) ;
331-
332- let stdout = '' ;
333- let stderr = '' ;
334- child . stdout . on ( 'data' , ( chunk : Buffer ) => {
335- stdout += chunk . toString ( ) ;
336- } ) ;
337- child . stderr . on ( 'data' , ( chunk : Buffer ) => {
338- stderr += chunk . toString ( ) ;
339- } ) ;
340-
341- const timer = setTimeout ( ( ) => {
342- child . kill ( ) ;
343- reject ( new Error ( `CLI timed out after 30s.\nstdout: ${ stdout } \nstderr: ${ stderr } ` ) ) ;
344- } , 30000 ) ;
345-
346- child . on ( 'close' , ( code ) => {
347- clearTimeout ( timer ) ;
348- if ( code !== 0 ) {
349- reject ( new Error ( `CLI exited with code ${ code } .\nstdout: ${ stdout } \nstderr: ${ stderr } ` ) ) ;
350- } else {
351- resolve ( stdout ) ;
352- }
353- } ) ;
354-
355- // Close stdin immediately — CLI runs in non-interactive mode
356- child . stdin . end ( ) ;
357- } ) ;
358- }
359-
360373 beforeAll ( async ( ) => {
361374 // 1. Spin up real DB + GraphQL server with simple-seed fixture
362375 const conn = await getConnections (
@@ -417,6 +430,8 @@ describe('CLI E2E — generated CLI against real DB', () => {
417430
418431 it ( 'should list with --limit, --where (dot-notation), and --fields' , async ( ) => {
419432 const output = await runCli (
433+ distDir ,
434+ tmpHome ,
420435 'animal' ,
421436 'list' ,
422437 '--limit' ,
@@ -451,6 +466,8 @@ describe('CLI E2E — generated CLI against real DB', () => {
451466 it ( 'should support cursor-based forward pagination (--after)' , async ( ) => {
452467 // First page: get 2 records
453468 const page1Output = await runCli (
469+ distDir ,
470+ tmpHome ,
454471 'animal' ,
455472 'list' ,
456473 '--limit' ,
@@ -469,6 +486,8 @@ describe('CLI E2E — generated CLI against real DB', () => {
469486
470487 // Second page: use the endCursor
471488 const page2Output = await runCli (
489+ distDir ,
490+ tmpHome ,
472491 'animal' ,
473492 'list' ,
474493 '--limit' ,
@@ -497,6 +516,8 @@ describe('CLI E2E — generated CLI against real DB', () => {
497516
498517 it ( 'should find-first with --where.name.equalTo' , async ( ) => {
499518 const output = await runCli (
519+ distDir ,
520+ tmpHome ,
500521 'animal' ,
501522 'find-first' ,
502523 '--where.name.equalTo' ,
@@ -521,6 +542,8 @@ describe('CLI E2E — generated CLI against real DB', () => {
521542
522543 it ( 'should combine --where + --orderBy + --fields for sorted filtered results' , async ( ) => {
523544 const output = await runCli (
545+ distDir ,
546+ tmpHome ,
524547 'animal' ,
525548 'list' ,
526549 '--where.species.equalTo' ,
@@ -550,6 +573,8 @@ describe('CLI E2E — generated CLI against real DB', () => {
550573
551574 it ( 'should handle empty result sets gracefully' , async ( ) => {
552575 const output = await runCli (
576+ distDir ,
577+ tmpHome ,
553578 'animal' ,
554579 'list' ,
555580 '--where.species.equalTo' ,
@@ -734,66 +759,6 @@ describe('CLI E2E — search commands against real DB', () => {
734759 let distDir : string ;
735760 let hasVector = false ;
736761
737- function runCli ( ...args : string [ ] ) : Promise < string > {
738- const runnerPath = path . join ( distDir , '_runner.js' ) ;
739- if ( ! fs . existsSync ( runnerPath ) ) {
740- fs . writeFileSync (
741- runnerPath ,
742- [
743- "const { parseArgv, Inquirerer } = require('inquirerer');" ,
744- "const { commands } = require('./cli/commands');" ,
745- 'const argv = parseArgv(process.argv);' ,
746- 'const prompter = new Inquirerer({ input: process.stdin, output: process.stdout, noTty: true });' ,
747- "commands(argv, prompter, { noTty: true }).then(() => process.exit(0)).catch(e => { console.error(e.message); process.exit(1); });" ,
748- ] . join ( '\n' ) ,
749- 'utf-8' ,
750- ) ;
751- }
752-
753- return new Promise < string > ( ( resolve , reject ) => {
754- const child = spawn (
755- process . execPath ,
756- [ runnerPath , ...args ] ,
757- {
758- env : {
759- ...process . env ,
760- APPSTASH_BASE_DIR : tmpHome ,
761- NODE_PATH : [
762- distDir ,
763- ...resolveNodePaths ( ) ,
764- ] . join ( path . delimiter ) ,
765- } ,
766- stdio : [ 'pipe' , 'pipe' , 'pipe' ] ,
767- } ,
768- ) ;
769-
770- let stdout = '' ;
771- let stderr = '' ;
772- child . stdout . on ( 'data' , ( chunk : Buffer ) => {
773- stdout += chunk . toString ( ) ;
774- } ) ;
775- child . stderr . on ( 'data' , ( chunk : Buffer ) => {
776- stderr += chunk . toString ( ) ;
777- } ) ;
778-
779- const timer = setTimeout ( ( ) => {
780- child . kill ( ) ;
781- reject ( new Error ( `CLI timed out after 30s.\nstdout: ${ stdout } \nstderr: ${ stderr } ` ) ) ;
782- } , 30000 ) ;
783-
784- child . on ( 'close' , ( code ) => {
785- clearTimeout ( timer ) ;
786- if ( code !== 0 ) {
787- reject ( new Error ( `CLI exited with code ${ code } .\nstdout: ${ stdout } \nstderr: ${ stderr } ` ) ) ;
788- } else {
789- resolve ( stdout ) ;
790- }
791- } ) ;
792-
793- child . stdin . end ( ) ;
794- } ) ;
795- }
796-
797762 beforeAll ( async ( ) => {
798763 // 1. Spin up real DB + GraphQL server with search-seed fixture
799764 const conn = await getConnections (
@@ -872,6 +837,8 @@ describe('CLI E2E — search commands against real DB', () => {
872837
873838 it ( 'should filter articles by tsvector search via --where.tsvTsv' , async ( ) => {
874839 const output = await runCli (
840+ distDir ,
841+ tmpHome ,
875842 'article' ,
876843 'list' ,
877844 '--where.tsvTsv' ,
@@ -902,6 +869,8 @@ describe('CLI E2E — search commands against real DB', () => {
902869
903870 it ( 'should filter articles by trgm similarity via dot-notation where' , async ( ) => {
904871 const output = await runCli (
872+ distDir ,
873+ tmpHome ,
905874 'article' ,
906875 'list' ,
907876 '--where.trgmTitle.value' ,
@@ -931,6 +900,8 @@ describe('CLI E2E — search commands against real DB', () => {
931900
932901 it ( 'should filter via fullTextSearch composite filter' , async ( ) => {
933902 const output = await runCli (
903+ distDir ,
904+ tmpHome ,
934905 'article' ,
935906 'list' ,
936907 '--where.fullTextSearch' ,
@@ -962,6 +933,8 @@ describe('CLI E2E — search commands against real DB', () => {
962933
963934 it ( 'should combine search filter with --limit for paginated results' , async ( ) => {
964935 const output = await runCli (
936+ distDir ,
937+ tmpHome ,
965938 'article' ,
966939 'list' ,
967940 '--where.tsvTsv' ,
@@ -998,6 +971,8 @@ describe('CLI E2E — search commands against real DB', () => {
998971 // The GraphQL server should reject it with a type error.
999972 // The CLI still exits 0 but returns { ok: false, errors: [...] }.
1000973 const output = await runCli (
974+ distDir ,
975+ tmpHome ,
1001976 'article' ,
1002977 'list' ,
1003978 '--where.vectorEmbedding.vector' ,
0 commit comments