Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .yarn/changelogs/frontend.b4e99528.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- version-type: patch -->
# frontend

## πŸ§ͺ Tests

- Replaced hard-coded `/tmp/...` paths in the payload-validator specs (`stack-form.spec.ts`, `import-stack.spec.ts`) with `os.tmpdir()`-based equivalents so the frontend `vitest` suite runs unmodified on Windows hosts (where `/tmp` does not exist) in addition to Linux and macOS.
17 changes: 17 additions & 0 deletions .yarn/changelogs/service.b4e99528.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!-- version-type: patch -->
# service

## πŸ› Bug Fixes

### Yarn prerequisite check now resolves on Windows

`checkYarn` previously called `execFile('yarn', ['--version'])`, which fails on Windows because Yarn is installed as a `yarn.cmd` / `yarn.ps1` shim β€” `execFile` does not consult `PATHEXT` and therefore reports the prerequisite as "not matched". On Windows the call is now routed through `cmd.exe /c yarn --version` so the shim is resolved like it is in any interactive shell. POSIX still uses `execFile` directly.

### Graceful service shutdown on Windows

`ProcessRunner.killProcessGroup` always invoked `taskkill /pid … /T /F` on Windows, regardless of the requested signal. Because `/F` is an unconditional force-kill, the SIGTERM-then-wait-then-SIGKILL escalation in `ServiceLifecycleManager` collapsed into a single force kill β€” managed services never received a chance to flush state on shutdown. The Windows branch now drops `/F` for non-`SIGKILL` signals and reserves it for `SIGKILL`, restoring the graceful-then-force escalation that POSIX already had.

## πŸ§ͺ Tests

- Added a Windows-specific `killProcessGroup` spec asserting `taskkill` is invoked **without** `/F` for `SIGTERM` and **with** `/F` for `SIGKILL`.
- Replaced hard-coded `/tmp/...` literals in service specs with `os.tmpdir()`-based paths (`join(tmpdir(), '...')`) so spec runs are portable across Linux, macOS, and Windows. Affected specs: `git-operations-service`, `git-watcher`, `git-head-watcher`, `service-delete-branch-action`, `import-export-actions`, `create-stack-action`, `service-branches-action`, `resolve-service-cwd`, `encrypt-existing-secrets`, `service-pipeline-orchestrator`, `service-env-resolver`, `stale-state-reconciler`, `service-file-manager`, `service-checkout-action`, `check-prerequisite-action`, `evaluate-prerequisites`. The two `/tmp` references that remain in `process-runner.spec.ts` are intentional β€” they pair with explicit `process.platform = 'linux'` mocks that exercise the POSIX `'/bin/sh'` branch.
6 changes: 6 additions & 0 deletions .yarn/changelogs/stack-craft.b4e99528.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- version-type: patch -->
# stack-craft

## πŸ§ͺ Tests

- Replaced hard-coded `/tmp/...` paths in Playwright E2E specs (`e2e/smoke.spec.ts`, `e2e/dogfooding.spec.ts`) and the frontend payload-validator specs (`stack-form.spec.ts`, `import-stack.spec.ts`) with `os.tmpdir()`-based paths so the suite runs unmodified on Windows hosts (where `/tmp` does not exist) in addition to Linux and macOS.
4 changes: 4 additions & 0 deletions .yarn/versions/b4e99528.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
releases:
frontend: patch
service: patch
stack-craft: patch
6 changes: 4 additions & 2 deletions e2e/dogfooding.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { randomBytes } from 'crypto'
import { tmpdir } from 'os'
import { join } from 'path'

import { expect, test } from '@playwright/test'

Expand Down Expand Up @@ -48,7 +50,7 @@ It is used to test the following features:

const dogfoodingPort = await getAvailablePort()
const mcpPort = await getAvailablePort()
const workingDirectory = `/tmp/e2e-dog-fooding-time-${uuid}`
const workingDirectory = join(tmpdir(), `e2e-dog-fooding-time-${uuid}`)

await page.goto('/')
await login(page)
Expand All @@ -59,7 +61,7 @@ It is used to test the following features:
name: stackName,
displayName,
description,
mainDirectory: '/tmp/e2e-test',
mainDirectory: join(tmpdir(), 'e2e-test'),
})
})

Expand Down
6 changes: 4 additions & 2 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { expect, test } from '@playwright/test'
import { tmpdir } from 'os'
import { join } from 'path'

import {
addRepository,
Expand All @@ -15,7 +17,7 @@ test('App Flow', async ({ page, browserName }) => {

const stackName = `e2e-test-stack-${uuid}`
const displayName = `E2E Test Stack - ${browserName} - ${uuid}`
const workingDirectory = `/tmp/e2e-test-stack-${uuid}`
const workingDirectory = join(tmpdir(), `e2e-test-stack-${uuid}`)

await page.goto('/')
await login(page)
Expand All @@ -26,7 +28,7 @@ test('App Flow', async ({ page, browserName }) => {
name: stackName,
displayName,
description: 'Created by E2E test',
mainDirectory: '/tmp/e2e-test',
mainDirectory: join(tmpdir(), 'e2e-test'),
})
})

Expand Down
9 changes: 6 additions & 3 deletions frontend/src/components/entity-forms/stack-form.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { tmpdir } from 'os'
import { describe, expect, it, vi } from 'vitest'

vi.mock('../app-routes.js', () => ({}))

import { isStackFormPayload } from './stack-form.js'

const TMP = tmpdir()

describe('isStackFormPayload', () => {
const validPayload = {
name: 'my-stack',
Expand All @@ -17,7 +20,7 @@ describe('isStackFormPayload', () => {
})

it('should accept payload without description (not validated)', () => {
expect(isStackFormPayload({ name: 'x', displayName: 'X', mainDirectory: '/tmp' })).toBe(true)
expect(isStackFormPayload({ name: 'x', displayName: 'X', mainDirectory: TMP })).toBe(true)
})

it('should throw on null (no null guard)', () => {
Expand All @@ -29,15 +32,15 @@ describe('isStackFormPayload', () => {
})

it('should reject when name is missing', () => {
expect(isStackFormPayload({ displayName: 'X', mainDirectory: '/tmp' })).toBe(false)
expect(isStackFormPayload({ displayName: 'X', mainDirectory: TMP })).toBe(false)
})

it('should reject when name is empty', () => {
expect(isStackFormPayload({ ...validPayload, name: '' })).toBe(false)
})

it('should reject when displayName is missing', () => {
expect(isStackFormPayload({ name: 'x', mainDirectory: '/tmp' })).toBe(false)
expect(isStackFormPayload({ name: 'x', mainDirectory: TMP })).toBe(false)
})

it('should reject when displayName is empty', () => {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/pages/import-export/import-stack.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it, vi } from 'vitest'

vi.mock('../../components/app-routes.js', () => ({}))
Expand All @@ -12,7 +14,7 @@ describe('isImportConfigPayload', () => {
it('should accept payload with additional env fields', () => {
expect(
isImportConfigPayload({
mainDirectory: '/tmp/stack',
mainDirectory: join(tmpdir(), 'stack'),
autoSetup: 'on',
envSource_GITHUB_TOKEN: 'inherit',
envValue_GITHUB_TOKEN: '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { usingAsync } from '@furystack/utils'
import { Prerequisite, PrerequisiteCheckResult, StackConfig } from 'common'
import type { PrerequisiteType } from 'common'
import { randomBytes } from 'crypto'
import { tmpdir } from 'os'
import { describe, expect, it, vi } from 'vitest'

import { runCheck, CheckPrerequisiteAction } from './check-prerequisite-action.js'
Expand Down Expand Up @@ -89,6 +90,36 @@ describe('CheckPrerequisiteAction', () => {
const result = await runCheck('yarn', { minimumVersion: '4.0.0' })
expect(result.satisfied).toBe(false)
})

it('should invoke `yarn --version` directly on POSIX', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'linux', writable: true })
try {
execFileMock.mockResolvedValue({ stdout: '4.6.0\n', stderr: '' })
await runCheck('yarn', { minimumVersion: '4.0.0' })
expect(execFileMock).toHaveBeenCalledWith('yarn', ['--version'], expect.objectContaining({ timeout: 30_000 }))
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true })
}
})

// Yarn ships as `yarn.cmd` / `yarn.ps1` on Windows; routing through `cmd.exe`
// is the load-bearing fix that lets PATHEXT resolve the shim. Guard against regression.
it('should route through cmd.exe on Windows to resolve the yarn shim', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'win32', writable: true })
try {
execFileMock.mockResolvedValue({ stdout: '4.6.0\n', stderr: '' })
await runCheck('yarn', { minimumVersion: '4.0.0' })
expect(execFileMock).toHaveBeenCalledWith(
'cmd.exe',
['/c', 'yarn', '--version'],
expect.objectContaining({ timeout: 30_000 }),
)
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true })
}
})
})

describe('git', () => {
Expand Down Expand Up @@ -264,7 +295,7 @@ describe('CheckPrerequisiteAction', () => {
})
await stackConfigStore.add({
stackName: 'test-stack',
mainDirectory: '/tmp',
mainDirectory: tmpdir(),
environmentVariables: {
STACK_CONFIGURED_VAR_12345: { source: 'custom', customValue: 'configured-value' },
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ const checkNode = async (config: { minimumVersion: string }): Promise<CheckResul
}

const checkYarn = async (config: { minimumVersion: string }): Promise<CheckResult> => {
const { stdout } = await execFileAsync('yarn', ['--version'], { timeout: COMMAND_TIMEOUT })
// Yarn ships as `yarn.cmd` / `yarn.ps1` shims on Windows, which `execFile` cannot resolve directly.
// Route through cmd.exe so PATHEXT applies. POSIX uses execFile directly to avoid an extra fork.
const isWindows = process.platform === 'win32'
const { stdout } = isWindows
? await execFileAsync('cmd.exe', ['/c', 'yarn', '--version'], { timeout: COMMAND_TIMEOUT })
: await execFileAsync('yarn', ['--version'], { timeout: COMMAND_TIMEOUT })
const version = extractVersion(stdout.trim())
if (!version) {
return { satisfied: false, output: `Could not parse Yarn version from: ${stdout.trim()}` }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { PrerequisiteCheckResultDataSet, PrerequisiteDataSet, StackConfigDataSet } from '../data-store/tokens.js'
import { getDataSetFor } from '@furystack/repository'
import { tmpdir } from 'os'
import { join } from 'path'

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { withTestInjector } from '../../test-helpers.js'
Expand Down Expand Up @@ -131,7 +133,7 @@ describe('evaluatePrerequisites', () => {
const ts = new Date().toISOString()
await getDataSetFor(elevated, StackConfigDataSet).add(elevated, {
stackName: 'my-stack',
mainDirectory: '/tmp/stack',
mainDirectory: join(tmpdir(), 'stack'),
environmentVariables: {
MY_VAR: { source: 'custom', customValue: 'secret' },
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe('ServiceBranchesAction', () => {
const repo = getRepository(injector)
await repo.getDataSetFor(StackConfig, 'stackName').add(injector, {
stackName: 'stack-1',
mainDirectory: '/tmp/stacks',
mainDirectory: join(tmpdir(), 'stacks'),
environmentVariables: {},
createdAt: '',
updatedAt: '',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { RequestError } from '@furystack/rest'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it, vi } from 'vitest'

import type { Injector } from '@furystack/inject'
Expand All @@ -13,7 +15,7 @@ const seedClonedService = async (elevated: Injector) => {
const repo = getRepository(elevated)
await repo.getDataSetFor((await import('common')).StackConfig, 'stackName').add(elevated, {
stackName: 'stack-1',
mainDirectory: '/tmp/stacks',
mainDirectory: join(tmpdir(), 'stacks'),
environmentVariables: {},
createdAt: '',
updatedAt: '',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { ServiceDefinitionDataSet, ServiceStatusDataSet } from '../../data-store/tokens.js'
import { getDataSetFor } from '@furystack/repository'
import type { ServiceStatus } from 'common'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it, vi } from 'vitest'
import { GitHeadWatcher } from '../../../services/git-head-watcher.js'
import { GitService } from '../../../services/git-service.js'
import { createMockActionContext, withTestInjector } from '../../../test-helpers.js'
import { ServiceDeleteBranchAction } from './service-delete-branch-action.js'
import '../../../test-shims.js'

const REPO_DIR = join(tmpdir(), 'repo')
const seed = async (
elevated: Parameters<Parameters<typeof withTestInjector>[0]>[0]['elevated'],
status: Partial<ServiceStatus> = {},
Expand Down Expand Up @@ -43,9 +47,11 @@ const mockGit = (overrides: Partial<GitService> = {}) =>
...overrides,
}) as unknown as GitService

vi.mock('../../../utils/resolve-service-cwd.js', () => ({
resolveServiceCwd: vi.fn().mockResolvedValue('/tmp/repo'),
}))
vi.mock('../../../utils/resolve-service-cwd.js', async () => {
const osMod = await import('os')
const pathMod = await import('path')
return { resolveServiceCwd: vi.fn().mockResolvedValue(pathMod.join(osMod.tmpdir(), 'repo')) }
})

describe('ServiceDeleteBranchAction', () => {
it('deletes a local branch that is not currently checked out', () =>
Expand All @@ -64,7 +70,7 @@ describe('ServiceDeleteBranchAction', () => {
})
const result = await ServiceDeleteBranchAction(ctx)
expect(result.chunk).toMatchObject({ success: true, deleted: 'feature/old' })
expect(git.deleteLocalBranch).toHaveBeenCalledWith('/tmp/repo', 'feature/old', false)
expect(git.deleteLocalBranch).toHaveBeenCalledWith(REPO_DIR, 'feature/old', false)
expect(git.checkout).not.toHaveBeenCalled()
}))

Expand All @@ -84,8 +90,8 @@ describe('ServiceDeleteBranchAction', () => {
body: { branch: 'feature/gone', force: true },
})
const result = await ServiceDeleteBranchAction(ctx)
expect(git.checkout).toHaveBeenCalledWith('/tmp/repo', 'main')
expect(git.deleteLocalBranch).toHaveBeenCalledWith('/tmp/repo', 'feature/gone', true)
expect(git.checkout).toHaveBeenCalledWith(REPO_DIR, 'main')
expect(git.deleteLocalBranch).toHaveBeenCalledWith(REPO_DIR, 'feature/gone', true)
expect(result.chunk).toMatchObject({ success: true, deleted: 'feature/gone', switchedTo: 'main' })
}))

Expand Down Expand Up @@ -137,7 +143,7 @@ describe('ServiceDeleteBranchAction', () => {
body: { branch: 'feature/gone', switchTo: 'develop' },
})
await ServiceDeleteBranchAction(ctx)
expect(git.checkout).toHaveBeenCalledWith('/tmp/repo', 'develop')
expect(git.checkout).toHaveBeenCalledWith(REPO_DIR, 'develop')
}))

it('throws when service does not exist', () =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { getDataSetFor } from '@furystack/repository'
import { randomBytes } from 'crypto'
import { tmpdir } from 'os'
import { join } from 'path'
import { beforeAll, describe, expect, it, vi } from 'vitest'

import { StackConfigDataSet, StackDefinitionDataSet } from '../../data-store/tokens.js'
Expand All @@ -8,11 +10,13 @@ import { CreateStackAction } from './create-stack-action.js'

type CreateStackBody = Parameters<typeof CreateStackAction>[0] extends { getBody: () => Promise<infer B> } ? B : never

const STACK_DIR = join(tmpdir(), 'my-stack')

const baseBody: CreateStackBody = {
name: 'my-stack',
displayName: 'My Stack',
description: 'Example',
mainDirectory: '/tmp/my-stack',
mainDirectory: STACK_DIR,
environmentVariables: {},
}

Expand All @@ -36,7 +40,7 @@ describe('CreateStackAction', () => {

const configs = await getDataSetFor(elevated, StackConfigDataSet).find(elevated, {})
expect(configs).toHaveLength(1)
expect(configs[0]?.mainDirectory).toBe('/tmp/my-stack')
expect(configs[0]?.mainDirectory).toBe(STACK_DIR)
})
})

Expand Down
Loading
Loading