From 75a440497bf5c70a907f90bcfe90368f82e875f5 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:47:00 +0800 Subject: [PATCH 1/5] drop in replacement --- packages/sv/src/addons/tests/better-auth/test.ts | 6 +++--- packages/sv/src/addons/tests/drizzle/test.ts | 12 ++++++------ packages/sv/src/addons/tests/eslint/test.ts | 8 ++++---- packages/sv/src/addons/tests/prettier/test.ts | 8 ++++---- packages/sv/src/cli/check.ts | 6 +++--- packages/sv/src/core/formatFiles.ts | 2 +- packages/sv/src/core/verifiers.ts | 9 ++++----- packages/sv/src/create/tests/check.ts | 12 ++++-------- packages/sv/src/testing.ts | 8 +++++--- 9 files changed, 34 insertions(+), 37 deletions(-) diff --git a/packages/sv/src/addons/tests/better-auth/test.ts b/packages/sv/src/addons/tests/better-auth/test.ts index 4954877ad..4be8b5c3e 100644 --- a/packages/sv/src/addons/tests/better-auth/test.ts +++ b/packages/sv/src/addons/tests/better-auth/test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; -import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { execSync } from 'tinyexec'; import betterAuth from '../../better-auth.ts'; import drizzle from '../../drizzle.ts'; import { setupTest } from '../_setup/suite.ts'; @@ -37,7 +37,7 @@ test.concurrent.for(testCases)('better-auth $variant', async (testCase, { page, fs.writeFileSync(envPath, envContent, 'utf8'); // Generate auth schema using better-auth CLI - execSync('npm run auth:schema', { cwd, stdio: 'pipe' }); + execSync('npm', ['run', 'auth:schema'], { nodeOptions: { cwd } }); // Verify schema has auth tables const schemaPath = path.resolve(cwd, `src/lib/server/db/schema.${language}`); @@ -46,7 +46,7 @@ test.concurrent.for(testCases)('better-auth $variant', async (testCase, { page, expect(schemaContent).toContain('./auth.schema'); // Push schema to DB - execSync('npm run db:push -- --force', { cwd, stdio: 'pipe' }); + execSync('npm', ['run', 'db:push', '--', '--force'], { nodeOptions: { cwd } }); /** ----- BROWSER SECTION ----- */ const { url, close } = await prepareServer({ cwd, page }); diff --git a/packages/sv/src/addons/tests/drizzle/test.ts b/packages/sv/src/addons/tests/drizzle/test.ts index e357d71cc..466b77163 100644 --- a/packages/sv/src/addons/tests/drizzle/test.ts +++ b/packages/sv/src/addons/tests/drizzle/test.ts @@ -1,4 +1,4 @@ -import { execSync } from 'node:child_process'; +import { execSync } from 'tinyexec'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; @@ -42,21 +42,21 @@ beforeAll(() => { const cwd = path.dirname(fileURLToPath(import.meta.url)); try { - execSync('docker --version', { cwd, stdio: 'pipe' }); + execSync('docker', ['--version'], { nodeOptions: { cwd } }); dockerInstalled = true; } catch { dockerInstalled = false; } - if (dockerInstalled) execSync('docker compose up --detach', { cwd, stdio: 'pipe' }); + if (dockerInstalled) execSync('docker', ['compose', 'up', '--detach'], { nodeOptions: { cwd } }); // cleans up the containers on interrupts (ctrl+c) process.addListener('SIGINT', () => { - if (dockerInstalled) execSync('docker compose down --volumes', { cwd, stdio: 'pipe' }); + if (dockerInstalled) execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); }); return () => { - if (dockerInstalled) execSync('docker compose down --volumes', { cwd, stdio: 'pipe' }); + if (dockerInstalled) execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); }; }); @@ -92,7 +92,7 @@ test.concurrent.for(testCases)( const pageServerPath = path.resolve(routes, `+page.server.${ts ? 'ts' : 'js'}`); fs.writeFileSync(pageServerPath, pageServer(ts ? 'ts' : 'js'), 'utf8'); - execSync('npm run db:push', { cwd, stdio: 'pipe' }); + execSync('npm', ['run', 'db:push'], { nodeOptions: { cwd } }); const { close } = await prepareServer({ cwd, page }); // kill server process when we're done diff --git a/packages/sv/src/addons/tests/eslint/test.ts b/packages/sv/src/addons/tests/eslint/test.ts index c458f5b3c..7dda9a5a0 100644 --- a/packages/sv/src/addons/tests/eslint/test.ts +++ b/packages/sv/src/addons/tests/eslint/test.ts @@ -1,6 +1,6 @@ -import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { execSync } from 'tinyexec'; import eslint from '../../eslint.ts'; import { setupTest } from '../_setup/suite.ts'; @@ -15,9 +15,9 @@ test.concurrent.for(testCases)('eslint $variant', (testCase, { expect, ...ctx }) const unlintedFile = 'let foo = "";\nif (Boolean(foo)) {\n//\n}'; fs.writeFileSync(path.resolve(cwd, 'src/lib/foo.js'), unlintedFile, 'utf8'); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).toThrow(); + expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).toThrow(); - expect(() => execSync('pnpm eslint --fix .', { cwd, stdio: 'pipe' })).not.toThrow(); + expect(() => execSync('pnpm', ['eslint', '--fix', '.'], { nodeOptions: { cwd } })).not.toThrow(); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).not.toThrow(); + expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).not.toThrow(); }); diff --git a/packages/sv/src/addons/tests/prettier/test.ts b/packages/sv/src/addons/tests/prettier/test.ts index 9af677bcc..096b0776e 100644 --- a/packages/sv/src/addons/tests/prettier/test.ts +++ b/packages/sv/src/addons/tests/prettier/test.ts @@ -1,7 +1,7 @@ import { log } from '@clack/prompts'; -import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { execSync } from 'tinyexec'; import { vi } from 'vitest'; import { ESLINT_VERSION } from '../../common.ts'; import prettier from '../../prettier.ts'; @@ -48,11 +48,11 @@ test.concurrent.for(testCases)('prettier $kind.type $variant', (testCase, { expe const unformattedFile = 'const foo = "bar"'; fs.writeFileSync(path.resolve(cwd, 'src/lib/foo.js'), unformattedFile, 'utf8'); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).toThrow(); + expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).toThrow(); - expect(() => execSync('pnpm format', { cwd, stdio: 'pipe' })).not.toThrow(); + expect(() => execSync('pnpm', ['format'], { nodeOptions: { cwd } })).not.toThrow(); - expect(() => execSync('pnpm lint', { cwd, stdio: 'pipe' })).not.toThrow(); + expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).not.toThrow(); } else if (testCase.kind.type === 'supported-eslint') { expect(fs.existsSync(path.resolve(cwd, 'eslint.config.js'))).toBe(true); diff --git a/packages/sv/src/cli/check.ts b/packages/sv/src/cli/check.ts index fd5f2dfa0..721470dd9 100644 --- a/packages/sv/src/cli/check.ts +++ b/packages/sv/src/cli/check.ts @@ -1,8 +1,8 @@ import { color, resolveCommandArray } from '@sveltejs/sv-utils'; import { Command } from 'commander'; import * as resolve from 'empathic/resolve'; -import { execSync } from 'node:child_process'; import process from 'node:process'; +import { execSync } from 'tinyexec'; import { forwardExitCode } from '../core/common.ts'; import { detectPackageManager } from '../core/package-manager.ts'; @@ -39,8 +39,8 @@ async function runCheck(cwd: string, args: string[]) { // avoids printing the stack trace for `sv` when `svelte-check` exits with an error code try { - const cmd = resolveCommandArray(pm, 'execute-local', ['svelte-check', ...args]).join(' '); - execSync(cmd, { stdio: 'inherit', cwd }); + const [cmd, ...cmdArgs] = resolveCommandArray(pm, 'execute-local', ['svelte-check', ...args]); + execSync(cmd, cmdArgs, { nodeOptions: { cwd, stdio: 'inherit' } }); } catch (error) { forwardExitCode(error); } finally { diff --git a/packages/sv/src/core/formatFiles.ts b/packages/sv/src/core/formatFiles.ts index f9da492ab..e97cab330 100644 --- a/packages/sv/src/core/formatFiles.ts +++ b/packages/sv/src/core/formatFiles.ts @@ -85,7 +85,7 @@ async function run( cwd: string ): Promise<{ error?: string; notFound?: boolean }> { try { - await exec(command, args, { nodeOptions: { cwd, stdio: 'pipe' }, throwOnError: true }); + await exec(command, args, { nodeOptions: { cwd }, throwOnError: true }); return {}; } catch (e) { // @ts-expect-error tinyexec rethrows the spawn error as-is diff --git a/packages/sv/src/core/verifiers.ts b/packages/sv/src/core/verifiers.ts index 884ef20de..1fdc5a425 100644 --- a/packages/sv/src/core/verifiers.ts +++ b/packages/sv/src/core/verifiers.ts @@ -1,5 +1,4 @@ -import { exec } from 'node:child_process'; -import { promisify } from 'node:util'; +import { exec } from 'tinyexec'; import type { AddonDefinition, SetupResult, Verification } from './config.ts'; import { UnsupportedError } from './errors.ts'; @@ -17,9 +16,9 @@ export function verifyCleanWorkingDirectory(cwd: string, gitCheck: boolean) { // there are no pending changes. If the below command is run outside of a git repository, // git will exit with a failing exit code, which will trigger the catch statement. // also see https://remarkablemark.org/blog/2017/10/12/check-git-dirty/#git-status - const asyncExec = promisify(exec); - const { stdout } = await asyncExec('git status --short', { - cwd + const { stdout } = await exec('git', ['status', '--short'], { + nodeOptions: { cwd }, + throwOnError: true }); if (stdout) { diff --git a/packages/sv/src/create/tests/check.ts b/packages/sv/src/create/tests/check.ts index 9aa03af48..7c9eac824 100644 --- a/packages/sv/src/create/tests/check.ts +++ b/packages/sv/src/create/tests/check.ts @@ -1,9 +1,7 @@ -import { type PromiseWithChild, exec as nodeExec } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; -import { exec } from 'tinyexec'; +import { exec, type Result } from 'tinyexec'; import { beforeAll, describe, expect, test } from 'vitest'; import { add, officialAddons } from '../../../../sv/src/index.ts'; import { createProject } from '../../cli/create.ts'; @@ -21,11 +19,9 @@ fs.mkdirSync(test_workspace_dir, { recursive: true }); fs.writeFileSync(path.join(test_workspace_dir, 'pnpm-workspace.yaml'), 'packages:\n - ./*\n'); -const exec_async = promisify(nodeExec); - beforeAll(async () => { const install = await exec('pnpm', ['install', '--no-frozen-lockfile'], { - nodeOptions: { cwd: test_workspace_dir, stdio: 'pipe' } + nodeOptions: { cwd: test_workspace_dir } }); if (install.exitCode !== 0) { throw new Error( @@ -38,7 +34,7 @@ beforeAll(async () => { * Tests in different templates can be run concurrently for a nice speedup locally, but tests within a template must be run sequentially. * It'd be better to group tests by template, but vitest doesn't support that yet. */ -const script_test_map = new Map PromiseWithChild]>>(); +const script_test_map = new Map Result]>>(); const templates = fs.readdirSync(resolve_path('../templates/')) as TemplateType[]; @@ -85,7 +81,7 @@ for (const template of templates.filter((t) => t !== 'addon')) { for (const script of scripts_to_test) { const tests = script_test_map.get(script) ?? []; - tests.push([`${template}-${types}`, () => exec_async(`pnpm ${script}`, { cwd })]); + tests.push([`${template}-${types}`, () => exec('pnpm', [script], { nodeOptions: { cwd } })]); script_test_map.set(script, tests); } diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts index 7af4a0a99..384b10546 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -1,10 +1,9 @@ import type { Page } from '@playwright/test'; -import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import pstree, { type PS } from 'ps-tree'; -import { exec, x } from 'tinyexec'; +import { exec, execSync, x } from 'tinyexec'; import type { TestProject } from 'vitest/node'; import { add, type AddonMap, type OptionMap } from './core/engine.ts'; import { addPnpmAllowBuilds } from './core/package-manager.ts'; @@ -230,7 +229,10 @@ export async function prepareServer({ expect }: PrepareServerOptions): Promise { // build project - if (buildCommand) execSync(buildCommand, { cwd, stdio: 'pipe' }); + if (buildCommand) { + const [cmd, ...args] = buildCommand.split(' '); + execSync(cmd, args, { nodeOptions: { cwd }, throwOnError: true }); + } // start preview server const { url, close } = await startPreview({ cwd, command: previewCommand }); From cd65defd8b9ee3d3e95038094730ac8b5433a3dc Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:48:01 +0800 Subject: [PATCH 2/5] add run helper that defaults cwd to testOutputPath --- packages/sv/src/cli/tests/cli.ts | 37 ++++++++++++++------------------ 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index 44994cf87..567a5489d 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -88,9 +88,15 @@ describe('cli', () => { ...args ]; + /** + * Same as `exec`. Defaults `cwd` to `testOutputPath` + */ + const run = (...args: Parameters) => + exec(args[0], args[1], { nodeOptions: { cwd: testOutputPath }, ...args[2] }); + // useful for debugging // console.log(`command`, `node ${allArgs.join(' ')}`); - const result = await exec('node', allArgs, { nodeOptions: { stdio: 'pipe' } }); + const result = await exec('node', allArgs); // cli finished well expect( @@ -191,24 +197,18 @@ describe('cli', () => { if (projectName === 'create-with-all-addons' && process.platform !== 'win32') { // the generated project lives inside this repo, so it must not join its workspace - const installResult = await exec( - 'pnpm', - ['install', '--no-frozen-lockfile', '--ignore-workspace'], - { nodeOptions: { stdio: 'pipe', cwd: testOutputPath } } - ); + const installResult = await run('pnpm', [ + 'install', + '--no-frozen-lockfile', + '--ignore-workspace' + ]); expect( installResult.exitCode, `pnpm install failed:\n stdout: ${installResult.stdout}\n stderr: ${installResult.stderr}` ).toBe(0); - await exec('pnpm', ['build'], { - nodeOptions: { stdio: 'pipe', cwd: testOutputPath } - }); - await exec('pnpm', ['auth:schema'], { - nodeOptions: { stdio: 'pipe', cwd: testOutputPath } - }); - const check = await exec('pnpm', ['check'], { - nodeOptions: { stdio: 'pipe', cwd: testOutputPath } - }); + await run('pnpm', ['build']); + await run('pnpm', ['auth:schema']); + const check = await run('pnpm', ['check']); expect( check.exitCode, `svelte-check failed:\n stdout: ${check.stdout}\n stderr: ${check.stderr}` @@ -217,9 +217,6 @@ describe('cli', () => { // `kit@next` moves fast - only a real install/build/check catches options it removed if (projectName === 'create-experimental-next' && process.platform !== 'win32') { - const run = (cmd: string, cmdArgs: string[]) => - exec(cmd, cmdArgs, { nodeOptions: { stdio: 'pipe', cwd: testOutputPath } }); - const install = await run('pnpm', [ 'install', '--no-frozen-lockfile', @@ -279,10 +276,8 @@ describe('cli', () => { for (const cmd of cmds) { // use npm here so the install doesn't walk up into the monorepo's // pnpm workspace and try to resolve packages from there - const res = await exec('npm', cmd, { + const res = await run('npm', cmd, { nodeOptions: { - stdio: 'pipe', - cwd: testOutputPath, env: { ...process.env, // allow npm under a repo whose packageManager is pnpm From 83624538b5d396a7ed83830f325479eb64b94a52 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Sat, 1 Aug 2026 18:49:35 +0800 Subject: [PATCH 3/5] refactor out `constructCommand` --- .../sv/src/addons/tests/better-auth/test.ts | 7 +++-- packages/sv/src/addons/tests/drizzle/test.ts | 19 ++++++++---- packages/sv/src/addons/tests/eslint/test.ts | 15 +++++++-- packages/sv/src/addons/tests/prettier/test.ts | 15 +++++++-- packages/sv/src/addons/tests/vitest/test.ts | 17 +++++----- packages/sv/src/cli/check.ts | 9 ++++-- packages/sv/src/cli/tests/cli.ts | 10 ++++-- packages/sv/src/core/engine.ts | 21 +++++++------ packages/sv/src/core/formatFiles.ts | 21 ++++++++----- packages/sv/src/core/package-manager.ts | 17 ++-------- packages/sv/src/core/verifiers.ts | 31 +++++++------------ packages/sv/src/testing.ts | 12 +++---- 12 files changed, 107 insertions(+), 87 deletions(-) diff --git a/packages/sv/src/addons/tests/better-auth/test.ts b/packages/sv/src/addons/tests/better-auth/test.ts index 4be8b5c3e..d29f06d0e 100644 --- a/packages/sv/src/addons/tests/better-auth/test.ts +++ b/packages/sv/src/addons/tests/better-auth/test.ts @@ -37,7 +37,7 @@ test.concurrent.for(testCases)('better-auth $variant', async (testCase, { page, fs.writeFileSync(envPath, envContent, 'utf8'); // Generate auth schema using better-auth CLI - execSync('npm', ['run', 'auth:schema'], { nodeOptions: { cwd } }); + execSync('npm', ['run', 'auth:schema'], { nodeOptions: { cwd }, throwOnError: true }); // Verify schema has auth tables const schemaPath = path.resolve(cwd, `src/lib/server/db/schema.${language}`); @@ -46,7 +46,10 @@ test.concurrent.for(testCases)('better-auth $variant', async (testCase, { page, expect(schemaContent).toContain('./auth.schema'); // Push schema to DB - execSync('npm', ['run', 'db:push', '--', '--force'], { nodeOptions: { cwd } }); + execSync('npm', ['run', 'db:push', '--', '--force'], { + nodeOptions: { cwd }, + throwOnError: true + }); /** ----- BROWSER SECTION ----- */ const { url, close } = await prepareServer({ cwd, page }); diff --git a/packages/sv/src/addons/tests/drizzle/test.ts b/packages/sv/src/addons/tests/drizzle/test.ts index 466b77163..911fc0226 100644 --- a/packages/sv/src/addons/tests/drizzle/test.ts +++ b/packages/sv/src/addons/tests/drizzle/test.ts @@ -1,8 +1,8 @@ -import { execSync } from 'tinyexec'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import { execSync } from 'tinyexec'; import { beforeAll, expect } from 'vitest'; import drizzle from '../../drizzle.ts'; import { setupTest } from '../_setup/suite.ts'; @@ -42,21 +42,28 @@ beforeAll(() => { const cwd = path.dirname(fileURLToPath(import.meta.url)); try { - execSync('docker', ['--version'], { nodeOptions: { cwd } }); + execSync('docker', ['--version'], { nodeOptions: { cwd }, throwOnError: true }); dockerInstalled = true; } catch { dockerInstalled = false; } - if (dockerInstalled) execSync('docker', ['compose', 'up', '--detach'], { nodeOptions: { cwd } }); + if (dockerInstalled) { + execSync('docker', ['compose', 'up', '--detach'], { + nodeOptions: { cwd }, + throwOnError: true + }); + } // cleans up the containers on interrupts (ctrl+c) process.addListener('SIGINT', () => { - if (dockerInstalled) execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); + if (dockerInstalled) + execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); }); return () => { - if (dockerInstalled) execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); + if (dockerInstalled) + execSync('docker', ['compose', 'down', '--volumes'], { nodeOptions: { cwd } }); }; }); @@ -92,7 +99,7 @@ test.concurrent.for(testCases)( const pageServerPath = path.resolve(routes, `+page.server.${ts ? 'ts' : 'js'}`); fs.writeFileSync(pageServerPath, pageServer(ts ? 'ts' : 'js'), 'utf8'); - execSync('npm', ['run', 'db:push'], { nodeOptions: { cwd } }); + execSync('npm', ['run', 'db:push'], { nodeOptions: { cwd }, throwOnError: true }); const { close } = await prepareServer({ cwd, page }); // kill server process when we're done diff --git a/packages/sv/src/addons/tests/eslint/test.ts b/packages/sv/src/addons/tests/eslint/test.ts index 7dda9a5a0..43c92c831 100644 --- a/packages/sv/src/addons/tests/eslint/test.ts +++ b/packages/sv/src/addons/tests/eslint/test.ts @@ -15,9 +15,18 @@ test.concurrent.for(testCases)('eslint $variant', (testCase, { expect, ...ctx }) const unlintedFile = 'let foo = "";\nif (Boolean(foo)) {\n//\n}'; fs.writeFileSync(path.resolve(cwd, 'src/lib/foo.js'), unlintedFile, 'utf8'); - expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should fail on unlinted file' + ).not.toBe(0); - expect(() => execSync('pnpm', ['eslint', '--fix', '.'], { nodeOptions: { cwd } })).not.toThrow(); + expect( + execSync('pnpm', ['eslint', '--fix', '.'], { nodeOptions: { cwd } }).exitCode, + 'eslint --fix should succeed' + ).toBe(0); - expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).not.toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should pass after fix' + ).toBe(0); }); diff --git a/packages/sv/src/addons/tests/prettier/test.ts b/packages/sv/src/addons/tests/prettier/test.ts index 096b0776e..ae0379f97 100644 --- a/packages/sv/src/addons/tests/prettier/test.ts +++ b/packages/sv/src/addons/tests/prettier/test.ts @@ -48,11 +48,20 @@ test.concurrent.for(testCases)('prettier $kind.type $variant', (testCase, { expe const unformattedFile = 'const foo = "bar"'; fs.writeFileSync(path.resolve(cwd, 'src/lib/foo.js'), unformattedFile, 'utf8'); - expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should fail on unformatted file' + ).not.toBe(0); - expect(() => execSync('pnpm', ['format'], { nodeOptions: { cwd } })).not.toThrow(); + expect( + execSync('pnpm', ['format'], { nodeOptions: { cwd } }).exitCode, + 'format should succeed' + ).toBe(0); - expect(() => execSync('pnpm', ['lint'], { nodeOptions: { cwd } })).not.toThrow(); + expect( + execSync('pnpm', ['lint'], { nodeOptions: { cwd } }).exitCode, + 'lint should pass after format' + ).toBe(0); } else if (testCase.kind.type === 'supported-eslint') { expect(fs.existsSync(path.resolve(cwd, 'eslint.config.js'))).toBe(true); diff --git a/packages/sv/src/addons/tests/vitest/test.ts b/packages/sv/src/addons/tests/vitest/test.ts index b00440972..f299ec4d4 100644 --- a/packages/sv/src/addons/tests/vitest/test.ts +++ b/packages/sv/src/addons/tests/vitest/test.ts @@ -1,6 +1,6 @@ -import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { execSync } from 'tinyexec'; import vitest from '../../vitest-addon.ts'; import { setupTest } from '../_setup/suite.ts'; @@ -13,16 +13,17 @@ test.concurrent.for(testCases)('vitest $variant', (testCase, { expect, ...ctx }) const cwd = ctx.cwd(testCase); expect( - spawnSync('pnpm exec playwright install chromium', { - cwd, - stdio: 'pipe', - shell: true, - timeout: 2 * 60_000 - }).status + execSync('pnpm', ['exec', 'playwright', 'install', 'chromium'], { + nodeOptions: { + cwd, + timeout: 2 * 60_000, + shell: true + } + }).exitCode ).toBe(0); expect( - spawnSync('pnpm test', { cwd, stdio: 'pipe', shell: true, timeout: 2 * 60_000 }).status + execSync('pnpm', ['test'], { nodeOptions: { cwd, shell: true, timeout: 2 * 60_000 } }).exitCode ).toBe(0); const viteFile = ['vite.config.ts', 'vite.config.js'] diff --git a/packages/sv/src/cli/check.ts b/packages/sv/src/cli/check.ts index 721470dd9..ae59243aa 100644 --- a/packages/sv/src/cli/check.ts +++ b/packages/sv/src/cli/check.ts @@ -1,4 +1,4 @@ -import { color, resolveCommandArray } from '@sveltejs/sv-utils'; +import { color, resolveCommand, resolveCommandArray } from '@sveltejs/sv-utils'; import { Command } from 'commander'; import * as resolve from 'empathic/resolve'; import process from 'node:process'; @@ -39,8 +39,11 @@ async function runCheck(cwd: string, args: string[]) { // avoids printing the stack trace for `sv` when `svelte-check` exits with an error code try { - const [cmd, ...cmdArgs] = resolveCommandArray(pm, 'execute-local', ['svelte-check', ...args]); - execSync(cmd, cmdArgs, { nodeOptions: { cwd, stdio: 'inherit' } }); + const cmd = resolveCommand(pm, 'execute-local', ['svelte-check', ...args])!; + execSync(cmd.command, cmd.args, { + nodeOptions: { cwd, stdio: 'inherit' }, + throwOnError: true + }); } catch (error) { forwardExitCode(error); } finally { diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index 567a5489d..070265945 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -89,10 +89,14 @@ describe('cli', () => { ]; /** - * Same as `exec`. Defaults `cwd` to `testOutputPath` + * Same as `exec`. but `cwd` defaults to `testOutputPath` */ - const run = (...args: Parameters) => - exec(args[0], args[1], { nodeOptions: { cwd: testOutputPath }, ...args[2] }); + const run = (...params: Parameters) => { + const [command, args, options = {}] = params; + options.nodeOptions ??= {}; + options.nodeOptions.cwd ??= testOutputPath; + return exec(command, args, options); + }; // useful for debugging // console.log(`command`, `node ${allArgs.join(' ')}`); diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index ccfc55b78..48d567b7c 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -376,9 +376,9 @@ export function prepareSvApi( } }, execute: async (commandArgs, stdio) => { - const { command, args } = resolveCommand(workspace.packageManager, 'execute', commandArgs)!; + const cmd = resolveCommand(workspace.packageManager, 'execute', commandArgs)!; - const executedCommand = [command, ...args].join(' '); + const executedCommand = [cmd, ...commandArgs].join(' '); if (!TESTING) { p.log.step( `${options?.executeOutputPrefix}Running external command ${color.optional(`(${executedCommand})`)}` @@ -386,18 +386,21 @@ export function prepareSvApi( } // adding --yes as the first parameter helps avoiding the "Need to install the following packages:" message - if (workspace.packageManager === 'npm') args.unshift('--yes'); + if (workspace.packageManager === 'npm') cmd.args.unshift('--yes'); try { - await exec(command, args, { + await exec(cmd.command, cmd.args, { nodeOptions: { cwd: workspace.cwd, stdio: TESTING ? 'pipe' : stdio }, throwOnError: true }); - } catch (error) { - const typedError = error as NonZeroExitError; - throw new Error(`Failed to execute scripts '${executedCommand}': ${typedError.message}`, { - cause: error - }); + } catch (e) { + let msg; + if (e instanceof NonZeroExitError || e instanceof Error) { + msg = `Failed to execute scripts '${executedCommand}': ${e.message}`; + } else { + msg = 'unknown error'; + } + throw new Error(msg, { cause: e }); } }, dependency: (pkg, version) => { diff --git a/packages/sv/src/core/formatFiles.ts b/packages/sv/src/core/formatFiles.ts index e97cab330..46039d665 100644 --- a/packages/sv/src/core/formatFiles.ts +++ b/packages/sv/src/core/formatFiles.ts @@ -2,7 +2,7 @@ import * as p from '@clack/prompts'; import { type AgentName, loadPackageJson, resolveCommand } from '@sveltejs/sv-utils'; import fs from 'node:fs'; import path from 'node:path'; -import { exec } from 'tinyexec'; +import { exec, NonZeroExitError } from 'tinyexec'; import { detectPackageManager } from './package-manager.ts'; import { findWorkspaceRoot } from './workspace.ts'; @@ -88,12 +88,17 @@ async function run( await exec(command, args, { nodeOptions: { cwd }, throwOnError: true }); return {}; } catch (e) { - // @ts-expect-error tinyexec rethrows the spawn error as-is - if (e?.code === 'ENOENT') return { notFound: true, error: `${command} not found` }; - // @ts-expect-error `output` is only present on tinyexec's `NonZeroExitError` - const output = e?.output as { stderr?: string; stdout?: string } | undefined; - // failures can land on either stream, so report both - const message = [output?.stderr, output?.stdout].filter(Boolean).join('\n').trim(); - return { error: message || (e instanceof Error ? e.message : 'unknown error') }; + // tinyexec rethrows the spawn error as-is + if ((e as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + return { notFound: true, error: `${command} not found` }; + } + if (e instanceof NonZeroExitError) { + // failures can land on either stream, so report both + const { stderr, stdout } = e.output ?? {}; + const message = [stderr, stdout].filter(Boolean).join('\n').trim(); + return { error: message || e.message }; + } + if (e instanceof Error) return { error: e.message }; + return { error: 'unknown error' }; } } diff --git a/packages/sv/src/core/package-manager.ts b/packages/sv/src/core/package-manager.ts index 56bc299e1..eb21e4a7b 100644 --- a/packages/sv/src/core/package-manager.ts +++ b/packages/sv/src/core/package-manager.ts @@ -1,13 +1,5 @@ import * as p from '@clack/prompts'; -import { - AGENTS, - type AgentName, - COMMANDS, - color, - constructCommand, - detect, - pnpm -} from '@sveltejs/sv-utils'; +import { AGENTS, type AgentName, color, detect, pnpm, resolveCommand } from '@sveltejs/sv-utils'; import { Option } from 'commander'; import * as find from 'empathic/find'; import fs from 'node:fs'; @@ -73,12 +65,9 @@ export async function installDependencies(agent: AgentName, cwd: string): Promis retainLog: true }); - const { command, args } = constructCommand(COMMANDS[agent].install, [])!; + const { command, args } = resolveCommand(agent, 'install', [])!; - const proc = exec(command, args, { - nodeOptions: { cwd, stdio: 'pipe' }, - throwOnError: false - }); + const proc = exec(command, args, { nodeOptions: { cwd }, throwOnError: false }); const output: string[] = []; try { diff --git a/packages/sv/src/core/verifiers.ts b/packages/sv/src/core/verifiers.ts index 1fdc5a425..465a26566 100644 --- a/packages/sv/src/core/verifiers.ts +++ b/packages/sv/src/core/verifiers.ts @@ -9,26 +9,17 @@ export function verifyCleanWorkingDirectory(cwd: string, gitCheck: boolean) { verifications.push({ name: 'clean working directory', run: async () => { - try { - // If a user has pending git changes the output of the following command will list - // all files that have been added/modified/deleted and thus the output will not be empty. - // In case the output of the command below is an empty text, we can safely assume - // there are no pending changes. If the below command is run outside of a git repository, - // git will exit with a failing exit code, which will trigger the catch statement. - // also see https://remarkablemark.org/blog/2017/10/12/check-git-dirty/#git-status - const { stdout } = await exec('git', ['status', '--short'], { - nodeOptions: { cwd }, - throwOnError: true - }); - - if (stdout) { - return { success: false, message: 'Uncommited changes found' }; - } - - return { success: true, message: undefined }; - } catch { - return { success: true, message: 'Not a git repository' }; - } + // If a user has pending git changes the output of the following command will list + // all files that have been added/modified/deleted and thus the output will not be empty. + // In case the output of the command below is an empty text, we can safely assume + // there are no pending changes. If the below command is run outside of a git repository, + // git will exit with a failing exit code. + // also see https://remarkablemark.org/blog/2017/10/12/check-git-dirty/#git-status + const result = await exec('git', ['status', '--short'], { nodeOptions: { cwd } }); + + if (result.exitCode !== 0) return { success: true, message: 'Not a git repository' }; + if (result.stdout) return { success: false, message: 'Uncommited changes found' }; + return { success: true, message: undefined }; } }); } diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts index 384b10546..db0595b75 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import pstree, { type PS } from 'ps-tree'; -import { exec, execSync, x } from 'tinyexec'; +import { exec, execSync } from 'tinyexec'; import type { TestProject } from 'vitest/node'; import { add, type AddonMap, type OptionMap } from './core/engine.ts'; import { addPnpmAllowBuilds } from './core/package-manager.ts'; @@ -81,11 +81,7 @@ async function startPreview({ command = 'npm run preview' }: PreviewOptions): Promise<{ url: string; close: () => Promise }> { const [cmd, ...args] = command.split(' '); - const proc = exec(cmd, args, { - nodeOptions: { cwd, stdio: 'pipe' }, - throwOnError: true, - timeout: 66_999 - }); + const proc = exec(cmd, args, { nodeOptions: { cwd }, throwOnError: true, timeout: 66_999 }); const close = async () => { if (!proc.pid) return; @@ -128,7 +124,7 @@ async function getProcessTree(pid: number) { async function terminate(pid: number) { if (process.platform === 'win32') { // on windows, use taskkill to terminate the process tree - await x('taskkill', ['/PID', `${pid}`, '/T', '/F']); + await exec('taskkill', ['/PID', `${pid}`, '/T', '/F']); return; } const children = await getProcessTree(pid); @@ -370,7 +366,7 @@ export function createSetupTest( const installDir = path.resolve(cwd, testName); const install = await exec('pnpm', ['install'], { - nodeOptions: { cwd: installDir, stdio: 'pipe' } + nodeOptions: { cwd: installDir } }); if (install.exitCode !== 0) { throw new Error( From b06ccd46fd6c3fdd94ddb3f010cec82d8955c3af Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Thu, 6 Aug 2026 03:14:36 +0800 Subject: [PATCH 4/5] add `isNodeError` --- packages/sv/src/core/common.ts | 4 ++++ packages/sv/src/core/formatFiles.ts | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/sv/src/core/common.ts b/packages/sv/src/core/common.ts index 37bbbb41f..75089b2df 100644 --- a/packages/sv/src/core/common.ts +++ b/packages/sv/src/core/common.ts @@ -364,3 +364,7 @@ export async function runAndValidateVerifications(verifications: Verification[]) } } } + +export function isNodeError(err: unknown): err is Error & NodeJS.ErrnoException { + return err instanceof Error; +} diff --git a/packages/sv/src/core/formatFiles.ts b/packages/sv/src/core/formatFiles.ts index 46039d665..2e0fd9bac 100644 --- a/packages/sv/src/core/formatFiles.ts +++ b/packages/sv/src/core/formatFiles.ts @@ -3,6 +3,7 @@ import { type AgentName, loadPackageJson, resolveCommand } from '@sveltejs/sv-ut import fs from 'node:fs'; import path from 'node:path'; import { exec, NonZeroExitError } from 'tinyexec'; +import { isNodeError } from './common.ts'; import { detectPackageManager } from './package-manager.ts'; import { findWorkspaceRoot } from './workspace.ts'; @@ -88,8 +89,7 @@ async function run( await exec(command, args, { nodeOptions: { cwd }, throwOnError: true }); return {}; } catch (e) { - // tinyexec rethrows the spawn error as-is - if ((e as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + if (isNodeError(e) && e.code === 'ENOENT') { return { notFound: true, error: `${command} not found` }; } if (e instanceof NonZeroExitError) { @@ -98,7 +98,9 @@ async function run( const message = [stderr, stdout].filter(Boolean).join('\n').trim(); return { error: message || e.message }; } - if (e instanceof Error) return { error: e.message }; + if (e instanceof Error) { + return { error: e.message }; + } return { error: 'unknown error' }; } } From 87c265c9811f3a5125438746cd65e468f3ebad62 Mon Sep 17 00:00:00 2001 From: Scott Wu Date: Fri, 14 Aug 2026 14:49:37 +0800 Subject: [PATCH 5/5] reduce git diff --- packages/sv/src/core/engine.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index 48d567b7c..1f2f9e3fa 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -376,9 +376,9 @@ export function prepareSvApi( } }, execute: async (commandArgs, stdio) => { - const cmd = resolveCommand(workspace.packageManager, 'execute', commandArgs)!; + const { command, args } = resolveCommand(workspace.packageManager, 'execute', commandArgs)!; - const executedCommand = [cmd, ...commandArgs].join(' '); + const executedCommand = [command, ...args].join(' '); if (!TESTING) { p.log.step( `${options?.executeOutputPrefix}Running external command ${color.optional(`(${executedCommand})`)}` @@ -386,21 +386,21 @@ export function prepareSvApi( } // adding --yes as the first parameter helps avoiding the "Need to install the following packages:" message - if (workspace.packageManager === 'npm') cmd.args.unshift('--yes'); + if (workspace.packageManager === 'npm') args.unshift('--yes'); try { - await exec(cmd.command, cmd.args, { + await exec(command, args, { nodeOptions: { cwd: workspace.cwd, stdio: TESTING ? 'pipe' : stdio }, throwOnError: true }); - } catch (e) { + } catch (error) { let msg; - if (e instanceof NonZeroExitError || e instanceof Error) { - msg = `Failed to execute scripts '${executedCommand}': ${e.message}`; + if (error instanceof NonZeroExitError || error instanceof Error) { + msg = `Failed to execute scripts '${executedCommand}': ${error.message}`; } else { msg = 'unknown error'; } - throw new Error(msg, { cause: e }); + throw new Error(msg, { cause: error }); } }, dependency: (pkg, version) => {