From 15a5dbf374ff618b20a865124fc80db33ade4898 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Oct 2025 22:14:15 +0000 Subject: [PATCH 1/3] Initial plan From 16f37498cdeedf40188027c928ad1d188cb484d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Oct 2025 22:27:06 +0000 Subject: [PATCH 2/3] Add watch restart testing infrastructure Co-authored-by: Apollon77 <11976694+Apollon77@users.noreply.github.com> --- test/js-adapters.test.js | 26 +++++- test/pure-ts-adapters.test.js | 32 ++++++- test/test-utils.js | 156 +++++++++++++++++++++++++++++++++- test/ts-adapters.test.js | 26 +++++- 4 files changed, 233 insertions(+), 7 deletions(-) diff --git a/test/js-adapters.test.js b/test/js-adapters.test.js index 3925699..671983d 100644 --- a/test/js-adapters.test.js +++ b/test/js-adapters.test.js @@ -4,7 +4,8 @@ const fs = require('node:fs'); const path = require('node:path'); const { runCommand, - runCommandWithSignal, + runCommandWithSignal, + runCommandWithFileChange, setupTestAdapter, cleanupTestAdapter, validateIoPackageJson, @@ -12,7 +13,8 @@ const { validateTypeScriptConfig, runDevServerSetupTest, validateRunTestOutput, - validateWatchTestOutput + validateWatchTestOutput, + validateWatchRestartOutput } = require('./test-utils'); const DEV_SERVER_ROOT = path.resolve(__dirname, '..'); @@ -100,5 +102,25 @@ describe('dev-server integration tests', function () { const output = result.stdout + result.stderr; validateWatchTestOutput(output, 'test-js'); }); + + it('should restart adapter when main file changes', async () => { + this.timeout(WATCH_TIMEOUT + 60000); // Extra time for file change and restart + + const devServerPath = path.join(DEV_SERVER_ROOT, 'dist', 'index.js'); + const mainFile = path.join(JS_ADAPTER_DIR, 'main.js'); + + const result = await runCommandWithFileChange('node', [devServerPath, 'watch'], { + cwd: JS_ADAPTER_DIR, + timeout: WATCH_TIMEOUT + 30000, + verbose: true, + initialMessage: /test-js\.0 \([\d]+\) state test-js\.0\.testVariable deleted/g, + finalMessage: /test-js\.0 \([\d]+\) state test-js\.0\.testVariable deleted/g, + fileToChange: mainFile, + }); + + const output = result.stdout + result.stderr; + validateWatchTestOutput(output, 'test-js'); + validateWatchRestartOutput(output, 'test-js'); + }); }); }); diff --git a/test/pure-ts-adapters.test.js b/test/pure-ts-adapters.test.js index 5b97476..044f750 100644 --- a/test/pure-ts-adapters.test.js +++ b/test/pure-ts-adapters.test.js @@ -4,7 +4,8 @@ const fs = require('node:fs'); const path = require('node:path'); const { runCommand, - runCommandWithSignal, + runCommandWithSignal, + runCommandWithFileChange, setupTestAdapter, cleanupTestAdapter, validateIoPackageJson, @@ -12,7 +13,8 @@ const { validateTypeScriptConfig, runDevServerSetupTest, validateRunTestOutput, - validateWatchTestOutput + validateWatchTestOutput, + validateWatchRestartOutput } = require('./test-utils'); const DEV_SERVER_ROOT = path.resolve(__dirname, '..'); @@ -161,5 +163,31 @@ describe('dev-server integration tests - Pure TypeScript', function () { 'esbuild-register should successfully transpile and execute TypeScript files' ); }); + + it('should restart adapter when TypeScript source file changes', async () => { + this.timeout(WATCH_TIMEOUT + 60000); // Extra time for file change and restart + + const devServerPath = path.join(DEV_SERVER_ROOT, 'dist', 'index.js'); + const mainFile = path.join(PURE_TS_ADAPTER_DIR, 'src', 'main.ts'); + + const result = await runCommandWithFileChange('node', [devServerPath, 'watch'], { + cwd: PURE_TS_ADAPTER_DIR, + timeout: WATCH_TIMEOUT + 30000, + verbose: true, + initialMessage: /test-pure-ts\.0 \([\d]+\) state test-pure-ts\.0\.testVariable deleted/g, + finalMessage: /test-pure-ts\.0 \([\d]+\) state test-pure-ts\.0\.testVariable deleted/g, + fileToChange: mainFile, + }); + + const output = result.stdout + result.stderr; + validateWatchTestOutput(output, 'test-pure-ts'); + validateWatchRestartOutput(output, 'test-pure-ts'); + + // Verify that esbuild-register is working by checking that TypeScript files are being executed + assert.ok( + output.includes('starting. Version 0.0.1'), + 'esbuild-register should successfully transpile and execute TypeScript files after restart' + ); + }); }); }); diff --git a/test/test-utils.js b/test/test-utils.js index 45949ba..c36b57f 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -429,9 +429,162 @@ function validateWatchTestOutput(output, adapterPrefix) { assert.ok(infoLines.length > 0, `No info logs found from ${adapterPrefix}.0 adapter`); } +/** + * Run watch command with file change trigger to test adapter restart + */ +function runCommandWithFileChange(command, args, options = {}) { + return new Promise((resolve, reject) => { + console.log(`Running with file change trigger: ${command} ${args.join(' ')}`); + const proc = spawn(command, args, { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: options.timeout || 30000, + env: options.env || process.env, + ...options + }); + + let stdout = ''; + let stderr = ''; + let killed = false; + let closed = false; + let resolvedOrRejected = false; + let fileChanged = false; + let restartDetected = false; + + const shutDown = () => { + if (resolvedOrRejected) return; + + console.log('Final timeout reached, sending SIGINT...'); + killed = true; + proc.kill('SIGINT'); + + // Give it 3 seconds to gracefully exit, then force kill + timeoutId = setTimeout(() => { + console.log('Checking if process has exited after SIGINT...'); + if (!resolvedOrRejected && !closed) { + console.log('Force killing with SIGKILL...'); + proc.kill('SIGKILL'); + } + + // Final fallback - resolve after another 2 seconds + timeoutId = setTimeout(() => { + if (!resolvedOrRejected) { + resolvedOrRejected = true; + if (!closed) { + reject(new Error('Process did not exit after SIGKILL')); + } + } + }, 5000); + }, 10000); + } + + proc.stdout.on('data', (data) => { + const str = data.toString(); + stdout += str; + if (options.verbose) { + console.log('STDOUT:', str.trim()); + } + + // Trigger file change after initial startup + if (!fileChanged && options.initialMessage && str.match(options.initialMessage)) { + console.log('Initial message detected, triggering file change...'); + fileChanged = true; + + // Wait a bit then trigger file change + setTimeout(() => { + if (!resolvedOrRejected && options.fileToChange) { + console.log(`Touching file: ${options.fileToChange}`); + try { + // Touch the file to trigger nodemon restart + const now = new Date(); + fs.utimesSync(options.fileToChange, now, now); + } catch (error) { + console.error('Error touching file:', error); + } + } + }, 5000); + } + + // Detect restart and wait for it to complete + if (fileChanged && !restartDetected && str.match(/restarting|restart/i)) { + console.log('Restart detected...'); + restartDetected = true; + } + + // After restart, wait for final message + if (restartDetected && options.finalMessage && str.match(options.finalMessage)) { + console.log('Final message after restart detected, shutting down...'); + setTimeout(shutDown, 10000); + } + }); + + proc.stderr.on('data', (data) => { + const str = data.toString(); + stderr += str; + if (options.verbose) { + console.log('STDERR:', str.trim()); + } + }); + + proc.on('close', (code) => { + closed = true; + console.log(`Process exited with code ${code}`); + clearTimeout(timeoutId); + if (resolvedOrRejected) return; + resolvedOrRejected = true; + + if (killed) { + setTimeout( () => resolve({ stdout, stderr, code, killed: true }), 5000); + } else if (code === 0 || code === 255) { + setTimeout( () => resolve({ stdout, stderr, code }), 5000); + } else { + setTimeout( () => reject(new Error(`Command failed with exit code ${code}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`)), 5000); + } + }); + + proc.on('error', (error) => { + console.log(`Process errored with error`, error); + clearTimeout(timeoutId); + if (resolvedOrRejected) return; + resolvedOrRejected = true; + reject(error); + }); + + // Auto-kill after timeout + let timeoutId = setTimeout(shutDown, (options.timeout || 30000) + 2000); + }); +} + +/** + * Validate that adapter restart occurred in watch mode + */ +function validateWatchRestartOutput(output, adapterPrefix) { + const assert = require('node:assert'); + + // Should see nodemon restart messages + assert.ok( + output.includes('restarting') || output.includes('restart'), + 'No nodemon restart message found in output' + ); + + // Should see adapter starting multiple times (initial + after restart) + const startingMatches = output.match(/starting\. Version 0\.0\.1/g); + assert.ok( + startingMatches && startingMatches.length >= 2, + `Adapter should start at least twice (initial + restart), but found ${startingMatches ? startingMatches.length : 0} instances` + ); + + // Should see the test variable deletion message at least twice + const testVarMatches = output.match(new RegExp(`state ${adapterPrefix}\\.0\\.testVariable deleted`, 'g')); + assert.ok( + testVarMatches && testVarMatches.length >= 2, + `Should see testVariable deletion at least twice (initial + restart), but found ${testVarMatches ? testVarMatches.length : 0} instances` + ); +} + module.exports = { runCommand, runCommandWithSignal, + runCommandWithFileChange, createTestAdapter, logSetupInfo, setupTestAdapter, @@ -443,5 +596,6 @@ module.exports = { validateTypeScriptConfig, runDevServerSetupTest, validateRunTestOutput, - validateWatchTestOutput + validateWatchTestOutput, + validateWatchRestartOutput }; diff --git a/test/ts-adapters.test.js b/test/ts-adapters.test.js index 59c78ab..7edda54 100644 --- a/test/ts-adapters.test.js +++ b/test/ts-adapters.test.js @@ -4,7 +4,8 @@ const fs = require('node:fs'); const path = require('node:path'); const { runCommand, - runCommandWithSignal, + runCommandWithSignal, + runCommandWithFileChange, setupTestAdapter, cleanupTestAdapter, validateIoPackageJson, @@ -12,7 +13,8 @@ const { validateTypeScriptConfig, runDevServerSetupTest, validateRunTestOutput, - validateWatchTestOutput + validateWatchTestOutput, + validateWatchRestartOutput } = require('./test-utils'); const DEV_SERVER_ROOT = path.resolve(__dirname, '..'); @@ -95,5 +97,25 @@ describe('dev-server integration tests', function () { const output = result.stdout + result.stderr; validateWatchTestOutput(output, 'test-ts'); }); + + it('should restart adapter when main file changes', async () => { + this.timeout(WATCH_TIMEOUT + 60000); // Extra time for file change and restart + + const devServerPath = path.join(DEV_SERVER_ROOT, 'dist', 'index.js'); + const mainFile = path.join(TS_ADAPTER_DIR, 'build', 'main.js'); + + const result = await runCommandWithFileChange('node', [devServerPath, 'watch'], { + cwd: TS_ADAPTER_DIR, + timeout: WATCH_TIMEOUT + 30000, + verbose: true, + initialMessage: /test-ts\.0 \([\d]+\) state test-ts\.0\.testVariable deleted/g, + finalMessage: /test-ts\.0 \([\d]+\) state test-ts\.0\.testVariable deleted/g, + fileToChange: mainFile, + }); + + const output = result.stdout + result.stderr; + validateWatchTestOutput(output, 'test-ts'); + validateWatchRestartOutput(output, 'test-ts'); + }); }); }); From ee04b7be36b729f1922aa5514a7ee366881be68f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Oct 2025 08:32:38 +0000 Subject: [PATCH 3/3] Refactor test utilities based on code review feedback Co-authored-by: Apollon77 <11976694+Apollon77@users.noreply.github.com> --- test/test-utils.js | 197 ++++++++++++++++----------------------------- 1 file changed, 69 insertions(+), 128 deletions(-) diff --git a/test/test-utils.js b/test/test-utils.js index c36b57f..bab35af 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -1,6 +1,7 @@ const { spawn } = require('node:child_process'); const path = require('node:path'); const fs = require('node:fs'); +const assert = require('node:assert'); /** * Run a command and return promise @@ -59,11 +60,16 @@ function runCommand(command, args, options = {}) { } /** - * Run a command with timeout and signal handling + * Core function to run a command with timeout and signal handling + * @param {string} command - Command to run + * @param {string[]} args - Command arguments + * @param {object} options - Options including timeout, verbose, finalMessage, onStdout callback + * @returns {Promise<{stdout: string, stderr: string, code: number, killed?: boolean}>} */ -function runCommandWithSignal(command, args, options = {}) { +function runCommandWithTimeout(command, args, options = {}) { return new Promise((resolve, reject) => { - console.log(`Running with signal handling: ${command} ${args.join(' ')}`); + const logPrefix = options.logPrefix || 'Running with signal handling'; + console.log(`${logPrefix}: ${command} ${args.join(' ')}`); const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], timeout: options.timeout || 30000, @@ -110,7 +116,14 @@ function runCommandWithSignal(command, args, options = {}) { if (options.verbose) { console.log('STDOUT:', str.trim()); } - if (options.finalMessage && str.match(options.finalMessage) && !closed && !resolvedOrRejected) { + + // Call custom stdout handler if provided + if (options.onStdout) { + options.onStdout(str, shutDown); + } + + // Default behavior: shutdown on final message + if (!options.onStdout && options.finalMessage && str.match(options.finalMessage) && !closed && !resolvedOrRejected) { console.log('Final message detected, shutting down...'); setTimeout(shutDown, 10000); } @@ -153,6 +166,13 @@ function runCommandWithSignal(command, args, options = {}) { }); } +/** + * Run a command with timeout and signal handling + */ +function runCommandWithSignal(command, args, options = {}) { + return runCommandWithTimeout(command, args, options); +} + /** * Create test adapter using @iobroker/create-adapter */ @@ -433,124 +453,47 @@ function validateWatchTestOutput(output, adapterPrefix) { * Run watch command with file change trigger to test adapter restart */ function runCommandWithFileChange(command, args, options = {}) { - return new Promise((resolve, reject) => { - console.log(`Running with file change trigger: ${command} ${args.join(' ')}`); - const proc = spawn(command, args, { - stdio: ['pipe', 'pipe', 'pipe'], - timeout: options.timeout || 30000, - env: options.env || process.env, - ...options - }); - - let stdout = ''; - let stderr = ''; - let killed = false; - let closed = false; - let resolvedOrRejected = false; - let fileChanged = false; - let restartDetected = false; - - const shutDown = () => { - if (resolvedOrRejected) return; - - console.log('Final timeout reached, sending SIGINT...'); - killed = true; - proc.kill('SIGINT'); - - // Give it 3 seconds to gracefully exit, then force kill - timeoutId = setTimeout(() => { - console.log('Checking if process has exited after SIGINT...'); - if (!resolvedOrRejected && !closed) { - console.log('Force killing with SIGKILL...'); - proc.kill('SIGKILL'); - } - - // Final fallback - resolve after another 2 seconds - timeoutId = setTimeout(() => { - if (!resolvedOrRejected) { - resolvedOrRejected = true; - if (!closed) { - reject(new Error('Process did not exit after SIGKILL')); - } - } - }, 5000); - }, 10000); - } - - proc.stdout.on('data', (data) => { - const str = data.toString(); - stdout += str; - if (options.verbose) { - console.log('STDOUT:', str.trim()); - } + let fileChanged = false; + let restartDetected = false; + + const onStdout = (str, shutDown) => { + // Trigger file change after initial startup + if (!fileChanged && options.initialMessage && str.match(options.initialMessage)) { + console.log('Initial message detected, triggering file change...'); + fileChanged = true; - // Trigger file change after initial startup - if (!fileChanged && options.initialMessage && str.match(options.initialMessage)) { - console.log('Initial message detected, triggering file change...'); - fileChanged = true; - - // Wait a bit then trigger file change - setTimeout(() => { - if (!resolvedOrRejected && options.fileToChange) { - console.log(`Touching file: ${options.fileToChange}`); - try { - // Touch the file to trigger nodemon restart - const now = new Date(); - fs.utimesSync(options.fileToChange, now, now); - } catch (error) { - console.error('Error touching file:', error); - } + // Wait a bit then trigger file change + setTimeout(() => { + if (options.fileToChange) { + console.log(`Touching file: ${options.fileToChange}`); + try { + // Touch the file to trigger nodemon restart + const now = new Date(); + fs.utimesSync(options.fileToChange, now, now); + } catch (error) { + console.error('Error touching file:', error); } - }, 5000); - } - - // Detect restart and wait for it to complete - if (fileChanged && !restartDetected && str.match(/restarting|restart/i)) { - console.log('Restart detected...'); - restartDetected = true; - } - - // After restart, wait for final message - if (restartDetected && options.finalMessage && str.match(options.finalMessage)) { - console.log('Final message after restart detected, shutting down...'); - setTimeout(shutDown, 10000); - } - }); - - proc.stderr.on('data', (data) => { - const str = data.toString(); - stderr += str; - if (options.verbose) { - console.log('STDERR:', str.trim()); - } - }); - - proc.on('close', (code) => { - closed = true; - console.log(`Process exited with code ${code}`); - clearTimeout(timeoutId); - if (resolvedOrRejected) return; - resolvedOrRejected = true; - - if (killed) { - setTimeout( () => resolve({ stdout, stderr, code, killed: true }), 5000); - } else if (code === 0 || code === 255) { - setTimeout( () => resolve({ stdout, stderr, code }), 5000); - } else { - setTimeout( () => reject(new Error(`Command failed with exit code ${code}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`)), 5000); - } - }); - - proc.on('error', (error) => { - console.log(`Process errored with error`, error); - clearTimeout(timeoutId); - if (resolvedOrRejected) return; - resolvedOrRejected = true; - reject(error); - }); + } + }, 5000); + } + + // Detect restart and wait for it to complete + if (fileChanged && !restartDetected && str.match(/restarting|restart/i)) { + console.log('Restart detected...'); + restartDetected = true; + } + + // After restart, wait for final message + if (restartDetected && options.finalMessage && str.match(options.finalMessage)) { + console.log('Final message after restart detected, shutting down...'); + setTimeout(shutDown, 10000); + } + }; - // Auto-kill after timeout - let timeoutId = setTimeout(shutDown, (options.timeout || 30000) + 2000); + return runCommandWithTimeout(command, args, { + ...options, + logPrefix: 'Running with file change trigger', + onStdout }); } @@ -558,26 +501,24 @@ function runCommandWithFileChange(command, args, options = {}) { * Validate that adapter restart occurred in watch mode */ function validateWatchRestartOutput(output, adapterPrefix) { - const assert = require('node:assert'); - // Should see nodemon restart messages assert.ok( - output.includes('restarting') || output.includes('restart'), + output.includes('restarting'), 'No nodemon restart message found in output' ); - // Should see adapter starting multiple times (initial + after restart) + // Should see adapter starting exactly twice (initial + after restart) const startingMatches = output.match(/starting\. Version 0\.0\.1/g); assert.ok( - startingMatches && startingMatches.length >= 2, - `Adapter should start at least twice (initial + restart), but found ${startingMatches ? startingMatches.length : 0} instances` + startingMatches && startingMatches.length === 2, + `Adapter should start exactly twice (initial + restart), but found ${startingMatches ? startingMatches.length : 0} instances` ); - // Should see the test variable deletion message at least twice + // Should see the test variable deletion message exactly twice const testVarMatches = output.match(new RegExp(`state ${adapterPrefix}\\.0\\.testVariable deleted`, 'g')); assert.ok( - testVarMatches && testVarMatches.length >= 2, - `Should see testVariable deletion at least twice (initial + restart), but found ${testVarMatches ? testVarMatches.length : 0} instances` + testVarMatches && testVarMatches.length === 2, + `Should see testVariable deletion exactly twice (initial + restart), but found ${testVarMatches ? testVarMatches.length : 0} instances` ); }