1- import { spawnSync } from "node:child_process" ;
1+ import { spawn , spawnSync } from "node:child_process" ;
2+ import { chmodSync , mkdtempSync , readFileSync , rmSync , writeFileSync } from "node:fs" ;
3+ import { tmpdir } from "node:os" ;
24import path from "node:path" ;
5+ import { setTimeout as delay } from "node:timers/promises" ;
36import { bundledPluginFile , bundledPluginRoot } from "openclaw/plugin-sdk/test-fixtures" ;
47import { beforeAll , describe , expect , it , vi } from "vitest" ;
58import {
@@ -23,6 +26,7 @@ import {
2326import { expectNoNodeFsScans } from "../../src/test-utils/fs-scan-assertions.js" ;
2427
2528const scriptPath = path . join ( process . cwd ( ) , "scripts" , "test-extension.mjs" ) ;
29+ const posixIt = process . platform === "win32" ? it . skip : it ;
2630
2731type RunGroupParams = {
2832 args : string [ ] ;
@@ -645,6 +649,52 @@ describe("scripts/test-extension.mjs", () => {
645649 ] ) ;
646650 } ) ;
647651
652+ posixIt (
653+ "preserves wrapper termination when the pnpm child exits cleanly after SIGTERM" ,
654+ async ( ) => {
655+ const root = mkdtempSync ( path . join ( tmpdir ( ) , "openclaw-test-extension-signal-" ) ) ;
656+ const fakePnpmPath = path . join ( root , "pnpm" ) ;
657+ const childPidPath = path . join ( root , "child.pid" ) ;
658+ const signaledPath = path . join ( root , "signaled" ) ;
659+
660+ writeFakePnpm ( fakePnpmPath ) ;
661+ const runner = spawn ( process . execPath , [ scriptPath , "firecrawl" ] , {
662+ cwd : process . cwd ( ) ,
663+ env : {
664+ ...process . env ,
665+ OPENCLAW_FAKE_PNPM_PID_PATH : childPidPath ,
666+ OPENCLAW_FAKE_PNPM_SIGNALED_PATH : signaledPath ,
667+ npm_execpath : fakePnpmPath ,
668+ } ,
669+ stdio : "ignore" ,
670+ } ) ;
671+ let childPid = 0 ;
672+
673+ try {
674+ await waitFor ( ( ) => fileExists ( childPidPath ) , 5_000 ) ;
675+ childPid = Number ( readFileSync ( childPidPath , "utf8" ) ) ;
676+ expect ( Number . isInteger ( childPid ) ) . toBe ( true ) ;
677+
678+ expect ( runner . pid ) . toBeGreaterThan ( 0 ) ;
679+ process . kill ( runner . pid ! , "SIGTERM" ) ;
680+ const result = await waitForClose ( runner ) ;
681+
682+ expect ( result ) . toEqual ( { code : null , signal : "SIGTERM" } ) ;
683+ await waitFor ( ( ) => fileExists ( signaledPath ) , 5_000 ) ;
684+ expect ( readFileSync ( signaledPath , "utf8" ) ) . toBe ( "SIGTERM" ) ;
685+ await waitFor ( ( ) => ! isProcessAlive ( childPid ) , 5_000 ) ;
686+ } finally {
687+ if ( runner . pid && isProcessAlive ( runner . pid ) ) {
688+ process . kill ( runner . pid , "SIGKILL" ) ;
689+ }
690+ if ( childPid && isProcessAlive ( childPid ) ) {
691+ process . kill ( childPid , "SIGKILL" ) ;
692+ }
693+ rmSync ( root , { force : true , recursive : true } ) ;
694+ }
695+ } ,
696+ ) ;
697+
648698 it ( "expands extension batch roots before applying exact Vitest excludes" , async ( ) => {
649699 const runGroup = vi . fn < ( ) => Promise < number > > ( ) . mockResolvedValue ( 0 ) ;
650700 await runExtensionBatchPlan (
@@ -737,3 +787,63 @@ describe("scripts/test-extension.mjs", () => {
737787 expect ( result . stderr ) . toContain ( `No tests found for ${ bundledPluginRoot ( extensionId ) } .` ) ;
738788 } ) ;
739789} ) ;
790+
791+ function writeFakePnpm ( filePath : string ) : void {
792+ writeFileSync (
793+ filePath ,
794+ [
795+ "#!/usr/bin/env node" ,
796+ 'const fs = require("node:fs");' ,
797+ "fs.writeFileSync(process.env.OPENCLAW_FAKE_PNPM_PID_PATH, String(process.pid));" ,
798+ 'process.on("SIGTERM", () => {' ,
799+ ' fs.writeFileSync(process.env.OPENCLAW_FAKE_PNPM_SIGNALED_PATH, "SIGTERM");' ,
800+ " process.exit(0);" ,
801+ "});" ,
802+ "setInterval(() => {}, 1000);" ,
803+ "" ,
804+ ] . join ( "\n" ) ,
805+ ) ;
806+ chmodSync ( filePath , 0o755 ) ;
807+ }
808+
809+ async function waitFor ( condition : ( ) => boolean , timeoutMs = 3_000 ) : Promise < void > {
810+ const startedAt = Date . now ( ) ;
811+ while ( ! condition ( ) ) {
812+ if ( Date . now ( ) - startedAt > timeoutMs ) {
813+ throw new Error ( "timed out waiting for condition" ) ;
814+ }
815+ await delay ( 25 ) ;
816+ }
817+ }
818+
819+ async function waitForClose (
820+ child : ReturnType < typeof spawn > ,
821+ timeoutMs = 5_000 ,
822+ ) : Promise < { code : number | null ; signal : NodeJS . Signals | null } > {
823+ return await Promise . race ( [
824+ new Promise < { code : number | null ; signal : NodeJS . Signals | null } > ( ( resolve ) => {
825+ child . once ( "close" , ( code , signal ) => resolve ( { code, signal } ) ) ;
826+ } ) ,
827+ delay ( timeoutMs ) . then ( ( ) => {
828+ throw new Error ( "timed out waiting for child close" ) ;
829+ } ) ,
830+ ] ) ;
831+ }
832+
833+ function fileExists ( filePath : string ) : boolean {
834+ try {
835+ readFileSync ( filePath ) ;
836+ return true ;
837+ } catch {
838+ return false ;
839+ }
840+ }
841+
842+ function isProcessAlive ( pid : number ) : boolean {
843+ try {
844+ process . kill ( pid , 0 ) ;
845+ return true ;
846+ } catch {
847+ return false ;
848+ }
849+ }
0 commit comments