mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
ci: run unit tests on windows (#1037)
* ci: run unit tests on windows * fix(migration-legacy): align workdir bucket key with agent-core computeWorkdirBucket used a local node:path-based resolve that yields backslash-separated paths on Windows, while agent-core's encodeWorkDirKey uses pathe (forward slashes on every platform). The SHA-256 inputs diverged, so migrated sessions were written to a bucket that the session picker never reads, making them invisible on Windows. Alias computeWorkdirBucket to encodeWorkDirKey so both sides stay byte-identical, drop the local slugify copy, and update the workdir-bucket test reference accordingly. * test(acp-adapter): expect platform-native separators in e2e-fs path The e2e-fs test asserted the fs/readTextFile wire path as the raw POSIX targetPath, but AcpKaos.toClientPath converts '/' to '\' when the inner LocalKaos reports pathClass 'win32' (Windows). On Windows the wire path became '\Users\test\x.ts' and the assertion failed. Mirror toClientPath in the test: expect backslash separators on win32 and the raw path otherwise. Implementation is unchanged. * test(sdk): normalize workDir and skillDir paths in session tests SessionStore.create/list and the skill loader normalize paths through pathe (forward slashes). The SDK tests compared the resulting workDir and skill loaded-dir against raw mkdtemp / node:path strings, which use backslashes on Windows (and node:fs realpath also returns backslashes for the skill dir), failing three toMatchObject assertions. Build the expected paths with agent-core's normalizeWorkDir so they match the internal pathe representation on every platform. The skill dir keeps its realpath() (the loader realpaths the root) and only normalizes separators. * test(skill): normalize realpath to forward slashes in scanner tests resolveSkillRoots normalizes every root.path through fs.realpath followed by replacing backslashes with forward slashes (scanner.ts). The scanner tests compared root.path against node:fs realpath directly, which returns backslashes on Windows, so twenty assertions failed (toEqual / toContain / toHaveLength) even though the resolved paths were identical. Wrap realpath at the top of the test file to mirror the implementation's normalization, so every comparison uses the same forward-slash form on every platform. * test: skip Unix-only permission tests on Windows The Unix file-permission assertions (mode bits like 0o600 / 0o700 and chmod 000 making a path unreadable) have no equivalent on Windows, which uses ACLs; fs.chmod there can only toggle the read-only bit. These six tests failed on Windows with mismatched mode values or a missing 40411. Skip them on win32 via it.skipIf(process.platform === 'win32'): oauth FileTokenStorage (0600 file, 0700 dir), agent-core BackgroundTaskPersistence (0700 tasks dir), agent-core createPerIdJsonStore (0700 subdir), migration-legacy atomicWrite (0600 file), and server fs:browse (chmod 000 -> 40411). * test(tui): make platform-sensitive assertions cross-platform The TUI implementations are already platform-aware (pathe-style paths, pathToFileURL, quoteShellArg cmd/POSIX quoting, Alt+V on Windows for paste expansion), but the tests hard-coded POSIX expectations and failed on Windows. Align the assertions with the implementation's platform behavior: footer-goal-badge matches the '[goal' badge prefix instead of /goal/ (toolbar tips contain '/goal'); tool-call expects backslash relative paths on win32; plan-box builds the file:// URL via pathToFileURL; custom-editor sends Alt+V on win32 for paste expansion; file-mention-provider normalizes the expected description to forward slashes; kimi-tui-startup builds the resume command with quoteShellArg; kimi-tui-message-flow builds the expected install path with resolve(). * test: align path assertions with pathe on Windows Several test suites asserted paths produced by node:path/node:os/node:fs against values that agent-core, node-sdk and kaos normalize through pathe (forward slashes). On Windows the two forms diverge (backslashes vs forward slashes), failing about 19 assertions. Mirror the implementation's normalization in the assertions via a local toPosix helper (or agent-core's normalizeWorkDir), so expected paths use forward slashes on every platform: kaos LocalKaos, node-sdk export/list/resume/config/transport sessions, cli FileMentionProvider, and agent-core skill-session. * test(native): build path expectations with node:path.resolve paths.mjs builds every path with node:path.resolve, which yields backslash-separated absolute paths on Windows. The path-helpers tests asserted against template strings that mixed the backslash appRoot with forward-slash segments, so Object.is failed on Windows even though the strings looked identical. Build the expectations with the same resolve(appRoot, ...) helper so the separators match on every platform. * fix: make Windows CI tests pass across all packages Fix the remaining Windows CI failures so the Windows test job can go green. The changes fall into a few categories: - Path separators: agent-core/node-sdk/kaos normalize paths via pathe (forward slashes); align test expectations and a couple of implementations (native cache base, workspace registry) with that. - Platform-only services: skip launchd/systemd manager suites on win32 (Windows uses schtasks). - Process/signal lifecycle: skip or relax tests that rely on POSIX signals / SIGTERM semantics that Windows does not support. - Hook shell syntax: rewrite hook test commands from POSIX shell (single quotes, semicolons, stderr redirects, if/then/fi) to node -e / .cjs files that run under cmd.exe. - CRLF: make Bash tool description stripping tolerate CRLF line endings. - Misc: realpath short-name divergence, port-retry timing, telemetry spawn, fs-watch timing, snapshot path normalization, etc. * fix: remove unused basename import in workspaceRegistryService Fix lint error (no-unused-vars): basename from node:path is no longer used after switching to posixBasename from pathe. * fix: align resume harness pathClass and wait for banner state on Windows Two more Windows CI fixes: - createResumeNoSideEffectKaos now reports pathClass 'win32' on Windows so tool descriptions (e.g. Glob's Windows note) match the live agent in expectResumeMatches, fixing usage/description deep-equal drift. - kimi-tui-startup once-banner test now waits for writeBannerDisplayState to land before asserting, since the atomic write can lag behind the render on Windows. * fix: resolve remaining Windows unit test failures Make the new Windows CI job green across agent-core, kaos, node-sdk and server: - Align the resume harness kaos pathClass with the live agent so platform-conditional tool descriptions (Glob's Windows note) match in expectResumeMatches instead of drifting on win32. - Rewrite hook commands in agent-core tests as cross-platform node one-liners; single-quote echo, >&2 and ';' do not work under cmd.exe. - Add .gitattributes enforcing LF so raw-imported templates (e.g. the compaction instruction) produce byte-identical token counts on Windows and POSIX. - Terminate the full process tree on Windows in both the hook runner and kaos (taskkill /T /F) so grandchildren cannot outlive their parent and keep the cwd locked. - Normalize workDir path separators in two kimi-sdk session tests to match the stored canonical form. - Avoid cmd.exe arg-quoting pitfalls in the kaos cmd.exe test, and run the Windows process-tree kill test from a script file with the pid path passed via argv. - Give the first fs-git e2e test more time on Windows and retry the temp-dir cleanup; skip the fs-watch overflow-burst assertion on Windows where fs-event coalescing prevents the single-window spike. * ci: retrigger checks * fix: resolve remaining Windows failures after merging main - Terminate the spawned git/gh process tree on Windows in FsGitService (taskkill /T /F on timeout) so a timed-out 'gh pr view' cannot leave a grandchild holding the workspace cwd, which made the fs-git e2e cleanup fail with EPERM. - Give the fs:git_status e2e suite a longer timeout on Windows and retry the temp-dir cleanup longer to ride out the slower child-process teardown. - Make the third-party plugin install trust test assert the resolved install path via node:path so it matches the Windows-resolved path (D:\tmp\...) as well as the POSIX one. * fix: align workspace registry roots and harden fs-git cleanup on Windows - workspace-registry test: compare normalized (forward-slash) roots, since the registry and session index both store workDir via pathe.resolve (forward slashes on every platform). realpath() yields backslashes on Windows and diverged from the stored root. - fs-git e2e: bump the temp-dir cleanup retries and the afterEach timeout, since Windows child-process teardown after server.close() is asynchronous and can keep the workspace cwd locked for several seconds. * test: stub openUrl in kimi-tui-message-flow feedback tests The /feedback command falls back to openUrl(FEEDBACK_ISSUE_URL) when submission fails, which spawned a real browser window on every test run. Mock #/utils/open-url (matching the existing login/message-replay/server test convention) so the suite never opens a browser. * test: harden fs-git e2e cleanup against Windows cwd locks On Windows, git/gh child processes and the session core process can outlive server.close() and keep the temp workspace as their cwd, so rmSync fails with EPERM even after a long retry. Add rmSyncRobust that retries and, if the cwd is still locked, swallows EPERM/EBUSY on Windows — the OS reclaims the temp dir and a cleanup hiccup must not fail an otherwise-passing test. * test: harden server e2e cleanup against async teardown races server.close() does not fully await the server's asynchronous teardown, so on a loaded CI runner the temp home/workspace dirs can still be held or written to when the afterEach rmSync runs, failing with EPERM (Windows) or ENOTEMPTY (Linux). Use a rmSyncRobust helper (retry + swallow EPERM/EBUSY/ENOTEMPTY) in the fs-git and question e2e cleanup. Also fix a leftover `throw err` (renamed to `throw error`) that broke the typecheck.
This commit is contained in:
parent
174101278d
commit
b51e13538d
72 changed files with 471 additions and 290 deletions
10
.gitattributes
vendored
Normal file
10
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Enforce LF line endings in the working tree on every platform so that
|
||||
# raw-imported text (e.g. `*.md?raw` templates) is byte-identical on Windows
|
||||
# and POSIX. Without this, Git for Windows' default `core.autocrlf=true`
|
||||
# checks text files out as CRLF, which shifts token-count snapshots.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binary assets — never normalize line endings.
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.png binary
|
||||
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
|
|
@ -44,6 +44,22 @@ jobs:
|
|||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm run test
|
||||
|
||||
test-windows:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm run test
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { createRequire } from 'node:module';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, win32 as pathWin32 } from 'node:path';
|
||||
import { join as joinPosix } from 'pathe';
|
||||
|
||||
import { KIMI_BUILD_INFO } from '#/cli/build-info';
|
||||
import { NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, buildManifestKey } from '../../scripts/native/manifest.mjs';
|
||||
|
|
@ -143,7 +144,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string {
|
|||
const cacheDirEnv = optionalEnvValue(env, 'KIMI_CODE_CACHE_DIR');
|
||||
if (cacheDirEnv !== null) return cacheDirEnv;
|
||||
|
||||
if (platform === 'darwin') return join(home, 'Library', 'Caches', 'kimi-code');
|
||||
if (platform === 'darwin') return joinPosix(home, 'Library', 'Caches', 'kimi-code');
|
||||
if (platform === 'win32') {
|
||||
const localAppData = optionalEnvValue(env, 'LOCALAPPDATA');
|
||||
return localAppData !== null
|
||||
|
|
@ -151,7 +152,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string {
|
|||
: pathWin32.join(home, 'AppData', 'Local', 'kimi-code', 'Cache');
|
||||
}
|
||||
|
||||
return join(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? join(home, '.cache'), 'kimi-code');
|
||||
return joinPosix(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? joinPosix(home, '.cache'), 'kimi-code');
|
||||
}
|
||||
|
||||
export function getNativeAssetCacheRoot(
|
||||
|
|
|
|||
|
|
@ -382,6 +382,7 @@ describe('kimi export', () => {
|
|||
exit: ((code: number) => {
|
||||
throw new ExitCalled(code);
|
||||
}) as ExportDeps['exit'],
|
||||
getShellEnv: () => ({ term: 'xterm-256color', shell: '/bin/zsh' }),
|
||||
});
|
||||
|
||||
await program.parseAsync(['node', 'kimi', 'export', 'ses_telemetry', '--output', output], {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type { ChildProcess } from 'node:child_process';
|
|||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createServer, type Server } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
import chalk, { Chalk } from 'chalk';
|
||||
import { Command } from 'commander';
|
||||
|
|
@ -905,7 +905,7 @@ describe('resolveDaemonProgram', () => {
|
|||
|
||||
it('normalizes a relative executable path against cwd outside SEA mode', async () => {
|
||||
const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon');
|
||||
expect(resolveDaemonProgram(['node', './kimi'], '/tmp/kimi-bin', '/usr/bin/node', false)).toBe('/tmp/kimi-bin/kimi');
|
||||
expect(resolveDaemonProgram(['node', './kimi'], '/tmp/kimi-bin', '/usr/bin/node', false)).toBe(resolve('/tmp/kimi-bin', './kimi'));
|
||||
});
|
||||
|
||||
it('returns execPath in SEA mode when argv[1] is a bare command name', async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ describe('cli version helpers', () => {
|
|||
const pkgPath = getHostPackageJsonPath();
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string };
|
||||
|
||||
expect(pkgPath.endsWith('/apps/kimi-code/package.json')).toBe(true);
|
||||
expect(pkgPath.endsWith(join('apps', 'kimi-code', 'package.json'))).toBe(true);
|
||||
expect(getHostPackageRoot()).toBe(dirname(pkgPath));
|
||||
expect(getVersion()).toBe(pkg.version);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
|
|
@ -18,6 +20,10 @@ import {
|
|||
SEA_SENTINEL_FUSE,
|
||||
} from '../../../scripts/native/paths.mjs';
|
||||
|
||||
// paths.mjs builds every path with node:path.resolve (backslashes on Windows).
|
||||
// Build expectations the same way so they match on every platform.
|
||||
const p = (...segments: string[]): string => resolve(appRoot, ...segments);
|
||||
|
||||
describe('targetTriple', () => {
|
||||
it('returns platform-arch when env unset', () => {
|
||||
expect(targetTriple({ platform: 'darwin', arch: 'arm64', env: {} })).toBe('darwin-arm64');
|
||||
|
|
@ -49,27 +55,27 @@ describe('executableName', () => {
|
|||
|
||||
describe('path helpers', () => {
|
||||
it('returns absolute intermediates dir under app root', () => {
|
||||
expect(nativeIntermediatesDir()).toBe(`${appRoot}/dist-native/intermediates`);
|
||||
expect(nativeIntermediatesDir()).toBe(p('dist-native/intermediates'));
|
||||
});
|
||||
|
||||
it('returns absolute bin dir per target', () => {
|
||||
expect(nativeBinDir('darwin-arm64')).toBe(`${appRoot}/dist-native/bin/darwin-arm64`);
|
||||
expect(nativeBinDir('darwin-arm64')).toBe(p('dist-native/bin/darwin-arm64'));
|
||||
});
|
||||
|
||||
it('returns absolute bin path with executable name', () => {
|
||||
expect(nativeBinPath('darwin-arm64', 'darwin')).toBe(
|
||||
`${appRoot}/dist-native/bin/darwin-arm64/kimi`,
|
||||
p('dist-native/bin/darwin-arm64/kimi'),
|
||||
);
|
||||
expect(nativeBinPath('win32-x64', 'win32')).toBe(
|
||||
`${appRoot}/dist-native/bin/win32-x64/kimi.exe`,
|
||||
p('dist-native/bin/win32-x64/kimi.exe'),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns intermediate artifact paths', () => {
|
||||
expect(nativeJsBundlePath()).toBe(`${appRoot}/dist-native/intermediates/main.cjs`);
|
||||
expect(nativeBlobPath()).toBe(`${appRoot}/dist-native/intermediates/kimi.blob`);
|
||||
expect(nativeJsBundlePath()).toBe(p('dist-native/intermediates/main.cjs'));
|
||||
expect(nativeBlobPath()).toBe(p('dist-native/intermediates/kimi.blob'));
|
||||
expect(nativeSeaConfigPath()).toBe(
|
||||
`${appRoot}/dist-native/intermediates/sea-config.json`,
|
||||
p('dist-native/intermediates/sea-config.json'),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -78,21 +84,21 @@ describe('path helpers', () => {
|
|||
});
|
||||
|
||||
it('returns native dist root', () => {
|
||||
expect(nativeDistRoot()).toBe(`${appRoot}/dist-native`);
|
||||
expect(nativeDistRoot()).toBe(p('dist-native'));
|
||||
});
|
||||
|
||||
it('returns manifest dir for target', () => {
|
||||
expect(nativeManifestDir('darwin-arm64')).toBe(
|
||||
`${appRoot}/dist-native/intermediates/native-assets/darwin-arm64`,
|
||||
p('dist-native/intermediates/native-assets/darwin-arm64'),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns artifacts dir', () => {
|
||||
expect(nativeArtifactsDir()).toBe(`${appRoot}/dist-native/artifacts`);
|
||||
expect(nativeArtifactsDir()).toBe(p('dist-native/artifacts'));
|
||||
});
|
||||
|
||||
it('returns smoke home', () => {
|
||||
expect(nativeSmokeHome()).toBe(`${appRoot}/dist-native/smoke-home`);
|
||||
expect(nativeSmokeHome()).toBe(p('dist-native/smoke-home'));
|
||||
});
|
||||
|
||||
it('has correct SEA sentinel fuse value', () => {
|
||||
|
|
|
|||
|
|
@ -461,7 +461,7 @@ describe('CustomEditor paste marker expansion', () => {
|
|||
|
||||
expect(editor.getText()).toMatch(/\[paste #1/);
|
||||
|
||||
editor.handleInput('\u0016');
|
||||
editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016');
|
||||
|
||||
expect(editor.getText()).not.toContain('[paste #');
|
||||
expect(editor.getText()).toContain(longText);
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ describe('FileMentionProvider', () => {
|
|||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.items.map((item) => item.value)).toContain(
|
||||
`@${join(extraDir, 'src', 'Additional.ts')}`,
|
||||
`@${join(extraDir, 'src', 'Additional.ts').replaceAll('\\', '/')}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -298,7 +298,7 @@ describe('FileMentionProvider', () => {
|
|||
const additionalResult = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() });
|
||||
expect(additionalResult).not.toBeNull();
|
||||
expect(additionalResult!.items.map((item) => item.value)).toContain(
|
||||
`@${join(extraDir, 'src', 'Additional.ts')}`,
|
||||
`@${join(extraDir, 'src', 'Additional.ts').replaceAll('\\', '/')}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -312,7 +312,7 @@ describe('FileMentionProvider', () => {
|
|||
|
||||
expect(result).not.toBeNull();
|
||||
const overlapItems = result!.items.filter(
|
||||
(item) => item.description === join(extraDir, 'src', 'Overlap.ts'),
|
||||
(item) => item.description === join(extraDir, 'src', 'Overlap.ts').replaceAll('\\', '/'),
|
||||
);
|
||||
expect(overlapItems).toHaveLength(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -855,9 +855,11 @@ describe('ToolCallComponent', () => {
|
|||
);
|
||||
|
||||
const out = strip(component.render(100).join('\n'));
|
||||
expect(out).toContain('Used Read (apps/kimi-code/src/main.ts)');
|
||||
const expectedReadPath =
|
||||
process.platform === 'win32' ? 'apps\\kimi-code\\src\\main.ts' : 'apps/kimi-code/src/main.ts';
|
||||
expect(out).toContain(`Used Read (${expectedReadPath})`);
|
||||
expect(out).not.toContain('/tmp/proj-a/apps');
|
||||
expect(component.getReadSnapshot().filePath).toBe('apps/kimi-code/src/main.ts');
|
||||
expect(component.getReadSnapshot().filePath).toBe(expectedReadPath);
|
||||
});
|
||||
|
||||
it('keeps Read paths outside the active workspace absolute', () => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { visibleWidth } from '@earendil-works/pi-tui';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
|
|
@ -70,7 +72,7 @@ describe('PlanBoxComponent', () => {
|
|||
it('wraps the basename in an OSC 8 hyperlink targeting file://', () => {
|
||||
const box = new PlanBoxComponent('# Hello', theme, darkColors.success, '/tmp/plan.md');
|
||||
const top = box.render(60)[0]!;
|
||||
expect(top).toContain(`${ESC}]8;;file:///tmp/plan.md${BEL}plan.md${ESC}]8;;${BEL}`);
|
||||
expect(top).toContain(`${ESC}]8;;${pathToFileURL('/tmp/plan.md').href}${BEL}plan.md${ESC}]8;;${BEL}`);
|
||||
// After stripping OSC + CSI, visible width must respect the requested render width.
|
||||
expect(strip(top).length).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
deleteAllKittyImages,
|
||||
|
|
@ -43,6 +43,11 @@ vi.mock('#/tui/commands/prompts', async (importOriginal) => {
|
|||
return { ...actual, promptFeedbackInput: vi.fn() };
|
||||
});
|
||||
|
||||
// /feedback falls back to opening GitHub Issues in a browser when not signed in
|
||||
// or when submission fails — stub it out so the test suite never spawns a
|
||||
// browser window.
|
||||
vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() }));
|
||||
|
||||
const ESC = String.fromCodePoint(0x1b);
|
||||
const BEL = String.fromCodePoint(0x07);
|
||||
|
||||
|
|
@ -3253,7 +3258,9 @@ command = "vim"
|
|||
confirm.handleInput('\r');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.installPlugin).toHaveBeenCalledWith('/tmp/proj-a/plugins/kimi-datasource');
|
||||
expect(session.installPlugin).toHaveBeenCalledWith(
|
||||
resolve('/tmp/proj-a', './plugins/kimi-datasource'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { BannerComponent } from '#/tui/components/chrome/banner';
|
|||
import { WelcomeComponent } from '#/tui/components/chrome/welcome';
|
||||
import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui';
|
||||
import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text';
|
||||
import { quoteShellArg } from '#/utils/shell-quote';
|
||||
import {
|
||||
DISABLE_TERMINAL_THEME_REPORTING,
|
||||
ENABLE_TERMINAL_THEME_REPORTING,
|
||||
|
|
@ -891,17 +892,12 @@ describe('KimiTUI startup', () => {
|
|||
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(driver.state.activeDialog).toBeNull();
|
||||
expect(copyTextToClipboardMock).toHaveBeenCalledWith(
|
||||
"cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'",
|
||||
);
|
||||
const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`;
|
||||
expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd);
|
||||
const transcript = driver.state.transcriptContainer.render(160).join('\n');
|
||||
expect(transcript).toContain('Current session is in a different working directory.');
|
||||
expect(transcript).toContain(
|
||||
"To resume, run: cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'",
|
||||
);
|
||||
expect(transcript).toContain(
|
||||
"To resume, run: cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'",
|
||||
);
|
||||
expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`);
|
||||
expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`);
|
||||
expect(transcript).toContain('Command copied to clipboard');
|
||||
});
|
||||
|
||||
|
|
@ -934,13 +930,10 @@ describe('KimiTUI startup', () => {
|
|||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(copyTextToClipboardMock).toHaveBeenCalledWith(
|
||||
"cd '/tmp/proj$(touch /tmp/pwned)' && kimi --resume 'ses-other-cwd'",
|
||||
);
|
||||
const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj$(touch /tmp/pwned)')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`;
|
||||
expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd);
|
||||
const transcript = driver.state.transcriptContainer.render(160).join('\n');
|
||||
expect(transcript).toContain(
|
||||
"To resume, run: cd '/tmp/proj$(touch /tmp/pwned)' && kimi --resume 'ses-other-cwd'",
|
||||
);
|
||||
expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`);
|
||||
});
|
||||
|
||||
it('exits after picking another cwd from the startup picker', async () => {
|
||||
|
|
@ -974,9 +967,8 @@ describe('KimiTUI startup', () => {
|
|||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(copyTextToClipboardMock).toHaveBeenCalledWith(
|
||||
"cd '/tmp/proj-b' && kimi --resume 'ses-other-cwd'",
|
||||
);
|
||||
const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && kimi --resume ${quoteShellArg('ses-other-cwd')}`;
|
||||
expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd);
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
expect(stop).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
|
@ -1663,6 +1655,16 @@ describe('KimiTUI startup', () => {
|
|||
).toBe(true);
|
||||
});
|
||||
|
||||
// writeBannerDisplayState runs after renderBanner; on Windows the atomic
|
||||
// write can lag behind the render, so wait for the state to land before
|
||||
// asserting it.
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const state = await readBannerDisplayState();
|
||||
expect(state.shown['once-banner']?.lastShownAt).toBeDefined();
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await expect(readBannerDisplayState()).resolves.toMatchObject({
|
||||
version: 1,
|
||||
shown: {
|
||||
|
|
|
|||
|
|
@ -165,9 +165,16 @@ describe('end-to-end FS reverse-RPC', () => {
|
|||
// The client saw exactly one fs/readTextFile request with the
|
||||
// expected path and matching sessionId.
|
||||
expect(bufferClient.readRequests).toHaveLength(1);
|
||||
|
||||
// AcpKaos forwards paths in client-native separators: when the inner
|
||||
// LocalKaos reports pathClass 'win32' (Windows), '/' is converted to '\\'
|
||||
// before the fs/readTextFile RPC (see kaos-acp.test.ts "uses win32-native
|
||||
// separators"). Mirror that here so the assertion holds on every platform.
|
||||
const expectedWirePath =
|
||||
process.platform === 'win32' ? targetPath.replaceAll('/', '\\') : targetPath;
|
||||
expect(bufferClient.readRequests[0]).toMatchObject({
|
||||
sessionId: capturedSessionId,
|
||||
path: targetPath,
|
||||
path: expectedWirePath,
|
||||
});
|
||||
|
||||
// Give the agent a tick to flush the queued sessionUpdate write
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
|
||||
import { Disposable, InstantiationType, registerSingleton } from '../../di';
|
||||
|
|
@ -248,7 +248,7 @@ async function runCommand(
|
|||
};
|
||||
if (options.timeoutMs !== undefined) {
|
||||
timer = setTimeout(() => {
|
||||
child.kill();
|
||||
killChild(child);
|
||||
finish({ exitCode: -1, stdout, stderr });
|
||||
}, options.timeoutMs);
|
||||
timer.unref?.();
|
||||
|
|
@ -270,6 +270,28 @@ async function runCommand(
|
|||
});
|
||||
}
|
||||
|
||||
function killChild(child: ChildProcess): void {
|
||||
// On Windows, `ChildProcess.kill()` only signals the direct child (e.g. the
|
||||
// `cmd.exe` wrapper when `shell` is involved, or the `git`/`gh` parent),
|
||||
// leaving grandchildren alive and holding the cwd. Terminate the whole
|
||||
// process tree so the working directory is released promptly.
|
||||
if (process.platform === 'win32' && child.pid !== undefined) {
|
||||
try {
|
||||
const killer = spawn('taskkill', ['/T', '/F', '/PID', String(child.pid)], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
killer.once('error', () => {});
|
||||
return;
|
||||
} catch {
|
||||
// fall through to the direct kill below
|
||||
}
|
||||
}
|
||||
try {
|
||||
child.kill();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function parsePullRequest(stdout: string): FsPullRequest | null {
|
||||
let raw: unknown;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { promises as fsp } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { basename as posixBasename } from 'pathe';
|
||||
import type { Stats } from 'node:fs';
|
||||
|
||||
import { Disposable, InstantiationType, registerSingleton } from '../../di';
|
||||
import { encodeWorkDirKey } from '../../session/store';
|
||||
import { encodeWorkDirKey, normalizeWorkDir } from '../../session/store';
|
||||
import { readSessionIndex } from '../../session/store/session-index';
|
||||
import { IEnvironmentService } from '../environment/environment';
|
||||
import { IEventService } from '../event/event';
|
||||
|
|
@ -90,7 +91,7 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
|
|||
result.push(
|
||||
await this.hydrate(
|
||||
id,
|
||||
{ root: workDir, name: basename(workDir), created_at: '', last_opened_at: '' },
|
||||
{ root: workDir, name: posixBasename(workDir), created_at: '', last_opened_at: '' },
|
||||
sessionCount,
|
||||
),
|
||||
);
|
||||
|
|
@ -111,9 +112,9 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
|
|||
}
|
||||
|
||||
async createOrTouch(root: string, name?: string): Promise<Workspace> {
|
||||
let realRoot: string;
|
||||
let stat: Stats;
|
||||
try {
|
||||
realRoot = await fsp.realpath(root);
|
||||
stat = await fsp.stat(root);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
|
|
@ -121,7 +122,15 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
|
|||
}
|
||||
throw err;
|
||||
}
|
||||
const workspaceId = encodeWorkDirKey(realRoot);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new WorkspaceRootNotFoundError(root);
|
||||
}
|
||||
// Normalize with pathe (NOT realpath) so the workspace id matches the
|
||||
// session store's `encodeWorkDirKey`, which also normalizes via pathe and
|
||||
// never resolves symlinks or 8.3 short names. Using `fsp.realpath` here
|
||||
// diverged from the session store on Windows and orphaned legacy sessions.
|
||||
const normalizedRoot = normalizeWorkDir(root);
|
||||
const workspaceId = encodeWorkDirKey(normalizedRoot);
|
||||
await fsp.mkdir(join(this.sessionsDir, workspaceId), { recursive: true, mode: 0o700 });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
|
@ -132,8 +141,8 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
|
|||
existing !== undefined
|
||||
? { ...existing, last_opened_at: now }
|
||||
: {
|
||||
root: realRoot,
|
||||
name: name ?? basename(realRoot),
|
||||
root: normalizedRoot,
|
||||
name: name ?? posixBasename(normalizedRoot),
|
||||
created_at: now,
|
||||
last_opened_at: now,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -206,8 +206,15 @@ function killProcess(child: ChildProcessWithoutNullStreams): void {
|
|||
}
|
||||
|
||||
function tryKillProcess(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
|
||||
if (process.platform === 'win32') {
|
||||
// On Windows, `ChildProcess.kill()` only signals the shell spawned by
|
||||
// `shell: true`, leaving grandchildren (the actual hook command) alive
|
||||
// and holding the cwd. `taskkill /T` terminates the whole process tree.
|
||||
killProcessTreeWindows(child, signal === 'SIGKILL');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (process.platform !== 'win32' && child.pid !== undefined) {
|
||||
if (child.pid !== undefined) {
|
||||
process.kill(-child.pid, signal);
|
||||
} else {
|
||||
child.kill(signal);
|
||||
|
|
@ -219,6 +226,21 @@ function tryKillProcess(child: ChildProcessWithoutNullStreams, signal: NodeJS.Si
|
|||
}
|
||||
}
|
||||
|
||||
function killProcessTreeWindows(child: ChildProcessWithoutNullStreams, force: boolean): void {
|
||||
if (child.pid === undefined) return;
|
||||
const args = force
|
||||
? ['/T', '/F', '/PID', String(child.pid)]
|
||||
: ['/T', '/PID', String(child.pid)];
|
||||
try {
|
||||
const killer = spawn('taskkill', args, { stdio: 'ignore', windowsHide: true });
|
||||
killer.once('error', () => {});
|
||||
} catch {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ function renderBashDescription(shellName: string): string {
|
|||
function withoutBackgroundDescription(description: string): string {
|
||||
return description
|
||||
.replace(
|
||||
/\n\nIf `run_in_background=true`,[\s\S]*?point them to the `\/tasks` command, which opens an interactive panel; it has no subcommands\./,
|
||||
/\r?\n\r?\nIf `run_in_background=true`,[\s\S]*?point them to the `\/tasks` command, which opens an interactive panel; it has no subcommands\./,
|
||||
'\n\nBackground execution is disabled for this agent. Do not set `run_in_background=true`.',
|
||||
)
|
||||
.replace(
|
||||
|
|
@ -145,7 +145,7 @@ function withoutBackgroundDescription(description: string): string {
|
|||
` For possibly long-running commands, set the \`timeout\` argument in seconds. The default is ${String(DEFAULT_TIMEOUT_S)}s; foreground commands allow up to ${String(MAX_TIMEOUT_S)}s.`,
|
||||
)
|
||||
.replace(
|
||||
/\n- Prefer `run_in_background=true`[\s\S]*?conversation to continue before the command finishes\./,
|
||||
/\r?\n- Prefer `run_in_background=true`[\s\S]*?conversation to continue before the command finishes\./,
|
||||
'\n- Do not set `run_in_background=true`; background task management tools are not available.',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ describe('BackgroundTaskPersistence', () => {
|
|||
expect(all.map((task) => task.taskId)).toEqual(['bash-11111111']);
|
||||
});
|
||||
|
||||
it('writeTask creates tasks dir with mode 0700', async () => {
|
||||
it.skipIf(process.platform === 'win32')('writeTask creates tasks dir with mode 0700', async () => {
|
||||
await persistence.writeTask(sample());
|
||||
const st = await stat(join(sessionDir, 'tasks'));
|
||||
// eslint-disable-next-line no-bitwise
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'pathe';
|
||||
|
||||
|
|
@ -2120,6 +2120,10 @@ function messageText(message: Message | undefined): string {
|
|||
}
|
||||
|
||||
function hookPayloadLoggerCommand(logPath: string): string {
|
||||
// Write the hook script to a file and run it with node, instead of
|
||||
// `node -e <json>` — cmd.exe on Windows mangles the escaped quotes in the
|
||||
// inline form and corrupts the script before it can run.
|
||||
const scriptPath = `${logPath}.cjs`;
|
||||
const script = [
|
||||
"const fs = require('node:fs');",
|
||||
"let input = '';",
|
||||
|
|
@ -2128,7 +2132,8 @@ function hookPayloadLoggerCommand(logPath: string): string {
|
|||
` fs.appendFileSync(${JSON.stringify(logPath)}, JSON.stringify(JSON.parse(input)) + '\\n');`,
|
||||
'});',
|
||||
].join('');
|
||||
return `node -e ${JSON.stringify(script)}`;
|
||||
writeFileSync(scriptPath, script);
|
||||
return `${process.execPath} ${scriptPath}`;
|
||||
}
|
||||
|
||||
function readHookPayloads(logPath: string): Array<Record<string, unknown>> {
|
||||
|
|
|
|||
|
|
@ -737,7 +737,7 @@ export class AgentTestContext {
|
|||
|
||||
async expectResumeMatches(): Promise<void> {
|
||||
const resumed = testAgent({
|
||||
kaos: createResumeNoSideEffectKaos(this.agent.config.cwd),
|
||||
kaos: createResumeNoSideEffectKaos(this.agent.config.cwd, this.agent.kaos.pathClass()),
|
||||
runtime: {
|
||||
urlFetcher: this.agent.toolServices?.urlFetcher,
|
||||
webSearcher: this.agent.toolServices?.webSearcher,
|
||||
|
|
@ -962,24 +962,29 @@ const failOnResumeGenerate: GenerateFn = async () => {
|
|||
throw new Error('Resume replay unexpectedly called the LLM');
|
||||
};
|
||||
|
||||
function createResumeNoSideEffectKaos(initialCwd: string): Kaos {
|
||||
function createResumeNoSideEffectKaos(
|
||||
initialCwd: string,
|
||||
pathClass: 'posix' | 'win32',
|
||||
): Kaos {
|
||||
const fail = (method: string): never => {
|
||||
throw new Error(`Resume replay unexpectedly called kaos.${method}`);
|
||||
};
|
||||
|
||||
// Replay may carry `config.update({cwd})` events that route through
|
||||
// `kaos.chdir(...)`; let those mutate an internal cwd field so replay
|
||||
// succeeds. Actual fs I/O methods remain forbidden.
|
||||
// succeeds. Actual fs I/O methods remain forbidden. `pathClass` mirrors
|
||||
// the live agent's kaos so platform-conditional tool descriptions (e.g.
|
||||
// Glob's Windows note) match the original in `expectResumeMatches`.
|
||||
let cwd = initialCwd;
|
||||
return {
|
||||
name: 'resume-no-side-effects',
|
||||
osEnv: TEST_OS_ENV,
|
||||
pathClass: () => 'posix',
|
||||
pathClass: () => pathClass,
|
||||
normpath: (p: string) => p,
|
||||
gethome: () => '/home/test',
|
||||
getcwd: () => cwd,
|
||||
withCwd: (next: string) => createResumeNoSideEffectKaos(next),
|
||||
withEnv: () => createResumeNoSideEffectKaos(cwd),
|
||||
withCwd: (next: string) => createResumeNoSideEffectKaos(next, pathClass),
|
||||
withEnv: () => createResumeNoSideEffectKaos(cwd, pathClass),
|
||||
chdir: async (next: string) => {
|
||||
cwd = next;
|
||||
},
|
||||
|
|
@ -1035,7 +1040,7 @@ function configStateSnapshot(agent: Agent): ResumeStateSnapshot['config'] {
|
|||
} catch {}
|
||||
|
||||
return {
|
||||
cwd: agent.config.cwd,
|
||||
cwd: agent.config.cwd.replaceAll('\\', '/'),
|
||||
provider,
|
||||
profileName: agent.config.profileName,
|
||||
thinkingLevel: agent.config.thinkingLevel,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ describe('Agent tools', () => {
|
|||
{
|
||||
event: 'PreToolUse',
|
||||
matcher: 'Bash',
|
||||
command: "echo 'blocked by PreToolUse' >&2; exit 2",
|
||||
command: 'node -e "process.stderr.write(\'blocked by PreToolUse\'); process.exit(2)"',
|
||||
},
|
||||
{
|
||||
event: 'PostToolUseFailure',
|
||||
|
|
|
|||
|
|
@ -497,7 +497,7 @@ describe('Agent turn flow', () => {
|
|||
{
|
||||
event: 'UserPromptSubmit',
|
||||
matcher: 'hooked input',
|
||||
command: "echo 'hook response 2'",
|
||||
command: 'node -e "process.stdout.write(\'hook response 2\')"',
|
||||
},
|
||||
]);
|
||||
const ctx = testAgent({ hookEngine });
|
||||
|
|
@ -558,12 +558,12 @@ describe('Agent turn flow', () => {
|
|||
{
|
||||
event: 'UserPromptSubmit',
|
||||
matcher: 'hooked input',
|
||||
command: "echo '{}'",
|
||||
command: 'node -e "process.stdout.write(\'{}\')"',
|
||||
},
|
||||
{
|
||||
event: 'UserPromptSubmit',
|
||||
matcher: 'hooked input',
|
||||
command: 'echo \'{"hookSpecificOutput":{}}\'',
|
||||
command: 'node -e "process.stdout.write(JSON.stringify({hookSpecificOutput:{}}))"',
|
||||
},
|
||||
]);
|
||||
const ctx = testAgent({ hookEngine });
|
||||
|
|
@ -621,7 +621,7 @@ describe('Agent turn flow', () => {
|
|||
{
|
||||
event: 'UserPromptSubmit',
|
||||
matcher: 'bad words',
|
||||
command: "echo 'no profanity' >&2; exit 2",
|
||||
command: 'node -e "process.stderr.write(\'no profanity\'); process.exit(2)"',
|
||||
},
|
||||
]);
|
||||
const ctx = testAgent({ hookEngine });
|
||||
|
|
@ -713,7 +713,7 @@ describe('Agent turn flow', () => {
|
|||
const hookEngine = new HookEngine([
|
||||
{
|
||||
event: 'Stop',
|
||||
command: "echo 'continue from hook' >&2; exit 2",
|
||||
command: 'node -e "process.stderr.write(\'continue from hook\'); process.exit(2)"',
|
||||
},
|
||||
]);
|
||||
const ctx = testAgent({ hookEngine });
|
||||
|
|
|
|||
|
|
@ -527,7 +527,7 @@ describe('goal session end-to-end', () => {
|
|||
{
|
||||
event: 'UserPromptSubmit',
|
||||
matcher: 'blocked objective',
|
||||
command: "echo 'blocked by policy' >&2; exit 2",
|
||||
command: 'node -e "process.stderr.write(\'blocked by policy\'); process.exit(2)"',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -451,7 +451,7 @@ max_context_size = 1000000
|
|||
async function findWireFile(root: string): Promise<string> {
|
||||
const suffix = join('agents', 'main', 'wire.jsonl');
|
||||
const entries = await readdir(root, { recursive: true });
|
||||
const match = entries.find((entry) => entry.endsWith(suffix));
|
||||
const match = entries.find((entry) => entry.replaceAll('\\', '/').endsWith(suffix));
|
||||
if (match === undefined) {
|
||||
throw new Error('wire.jsonl not found under session home');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ describe('plan-mode bootstrap from config.defaultPlanMode', () => {
|
|||
async function countPlanModeEnters(): Promise<number> {
|
||||
const suffix = join('agents', 'main', 'wire.jsonl');
|
||||
const entries = await readdir(homeDir, { recursive: true });
|
||||
const match = entries.find((entry) => entry.endsWith(suffix));
|
||||
const match = entries.find((entry) => entry.replaceAll('\\', '/').endsWith(suffix));
|
||||
if (match === undefined) {
|
||||
throw new Error('wire.jsonl not found under session home');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ import {
|
|||
type TelemetryContextRecord,
|
||||
} from '../fixtures/telemetry';
|
||||
|
||||
// agent-core renders skill paths with forward slashes (pathe). Mirror that in
|
||||
// path assertions so they hold on Windows, where node:fs.realpath produces
|
||||
// backslashes.
|
||||
const toPosix = (p: string): string => p.replaceAll('\\', '/');
|
||||
|
||||
describe('HarnessAPI session skills', () => {
|
||||
let tmp: string;
|
||||
let homeDir: string;
|
||||
|
|
@ -193,7 +198,7 @@ describe('HarnessAPI session skills', () => {
|
|||
const records = await readMainWire(created.sessionDir);
|
||||
const prompt = records.find((record) => record['type'] === 'turn.prompt');
|
||||
const userMessage = records.find((record) => record['type'] === 'context.append_message');
|
||||
const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'phase-one-review'));
|
||||
const skillDir = toPosix(await realpath(join(workDir, '.kimi-code', 'skills', 'phase-one-review')));
|
||||
const expectedPrompt = [
|
||||
'User activated the skill "phase-one-review". Follow the loaded skill instructions.',
|
||||
'',
|
||||
|
|
@ -283,7 +288,7 @@ describe('HarnessAPI session skills', () => {
|
|||
|
||||
const records = await readMainWire(created.sessionDir);
|
||||
const prompt = records.find((record) => record['type'] === 'turn.prompt');
|
||||
const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'templated-review'));
|
||||
const skillDir = toPosix(await realpath(join(workDir, '.kimi-code', 'skills', 'templated-review')));
|
||||
const expectedPrompt = [
|
||||
'User activated the skill "templated-review". Follow the loaded skill instructions.',
|
||||
'',
|
||||
|
|
@ -330,7 +335,7 @@ describe('HarnessAPI session skills', () => {
|
|||
const prompt = records.find((record) => record['type'] === 'turn.prompt');
|
||||
const text = (prompt as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text;
|
||||
|
||||
const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'brainstorm'));
|
||||
const skillDir = toPosix(await realpath(join(workDir, '.kimi-code', 'skills', 'brainstorm')));
|
||||
expect(text).toContain('User activated the skill "brainstorm". Follow the loaded skill instructions.');
|
||||
expect(text).toContain(
|
||||
`<kimi-skill-loaded name="brainstorm" trigger="user-slash" source="project" dir="${skillDir}" args="">`,
|
||||
|
|
@ -434,7 +439,7 @@ describe('HarnessAPI session skills', () => {
|
|||
const resumed = await second.rpc.resumeSession({ sessionId: created.id });
|
||||
|
||||
expect(second.events.some((event) => event.type === 'skill.activated')).toBe(false);
|
||||
const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'phase-one-review'));
|
||||
const skillDir = toPosix(await realpath(join(workDir, '.kimi-code', 'skills', 'phase-one-review')));
|
||||
const context = await second.rpc.getContext({ sessionId: created.id, agentId: 'main' });
|
||||
expect(context.history).toMatchObject([
|
||||
{
|
||||
|
|
@ -514,7 +519,7 @@ describe('HarnessAPI session skills', () => {
|
|||
await second.rpc.resumeSession({ sessionId: created.id });
|
||||
const context = await second.rpc.getContext({ sessionId: created.id, agentId: 'main' });
|
||||
|
||||
const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'bundled-tool'));
|
||||
const skillDir = toPosix(await realpath(join(workDir, '.kimi-code', 'skills', 'bundled-tool')));
|
||||
const skillMessage = context.history.find(
|
||||
(entry) =>
|
||||
entry.origin?.kind === 'skill_activation' &&
|
||||
|
|
@ -528,7 +533,7 @@ describe('HarnessAPI session skills', () => {
|
|||
// ...and it is the directory that actually holds the bundled script, so an
|
||||
// agent reading the context can resolve the resource by relative path.
|
||||
expect(join(skillDir, 'scripts', 'run.sh')).toBe(
|
||||
await realpath(join(scriptDir, 'run.sh')),
|
||||
toPosix(await realpath(join(scriptDir, 'run.sh'))),
|
||||
);
|
||||
// Guard the regression: the path is surfaced by the wrapper, not because
|
||||
// the skill body happened to mention it.
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ describe('HookEngine', () => {
|
|||
{
|
||||
event: 'PreToolUse',
|
||||
matcher: 'ReadFile',
|
||||
command: "echo 'blocked' >&2; exit 2",
|
||||
command: 'node -e "process.stderr.write(\'blocked\'); process.exit(2)"',
|
||||
timeout: 5,
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -60,24 +60,21 @@ async function importEngine(): Promise<HookEngineCtor> {
|
|||
describe('HookEngine integration', () => {
|
||||
it('blocks a dangerous Shell command and allows a safe one via a PreToolUse script hook', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'kimi-hooks-int-'));
|
||||
const script = join(dir, 'block-rm.sh');
|
||||
// Use node for the body to keep the test runtime-agnostic.
|
||||
const script = join(dir, 'block-rm.cjs');
|
||||
// Node script body (avoids bash-only syntax so the test runs on Windows).
|
||||
writeFileSync(
|
||||
script,
|
||||
[
|
||||
'#!/bin/bash',
|
||||
'CMD=$(node -e "let s=\\"\\";process.stdin.on(\\"data\\",d=>s+=d);process.stdin.on(\\"end\\",()=>{try{const o=JSON.parse(s);process.stdout.write((o.tool_input&&o.tool_input.command)||\\"\\");}catch(e){}})")',
|
||||
'if echo "$CMD" | grep -q "rm -rf"; then echo "Blocked: rm -rf" >&2; exit 2; fi',
|
||||
'exit 0',
|
||||
'',
|
||||
"let s='';",
|
||||
"process.stdin.on('data',d=>s+=d);",
|
||||
"process.stdin.on('end',()=>{try{const o=JSON.parse(s);const c=(o.tool_input&&o.tool_input.command)||'';if(/rm -rf/.test(c)){process.stderr.write('Blocked: rm -rf');process.exit(2);}process.exit(0);}catch(e){}});",
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
chmodSync(script, 0o755);
|
||||
|
||||
const HookEngine = await importEngine();
|
||||
const engine = new HookEngine(
|
||||
[{ event: 'PreToolUse', matcher: 'Shell', command: script, timeout: 5 }],
|
||||
[{ event: 'PreToolUse', matcher: 'Shell', command: `${process.execPath} ${script}`, timeout: 5 }],
|
||||
{ cwd: dir },
|
||||
);
|
||||
|
||||
|
|
@ -101,7 +98,7 @@ describe('HookEngine integration', () => {
|
|||
{
|
||||
event: 'Stop',
|
||||
command:
|
||||
'echo \'{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"tests not written"}}\'',
|
||||
"node -e \"process.stdout.write(JSON.stringify({hookSpecificOutput:{permissionDecision:'deny',permissionDecisionReason:'tests not written'}}))\"",
|
||||
timeout: 5,
|
||||
},
|
||||
]);
|
||||
|
|
@ -223,7 +220,7 @@ timeout = 5
|
|||
const engine = new HookEngine([
|
||||
{
|
||||
event: 'UserPromptSubmit',
|
||||
command: "echo 'no profanity' >&2; exit 2",
|
||||
command: "node -e \"process.stderr.write('no profanity');process.exit(2)\"",
|
||||
timeout: 5,
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ describe('runHook process runner', () => {
|
|||
|
||||
it('parses stdout JSON message into a hook result message', async () => {
|
||||
const runHook = await importRunHook();
|
||||
const result = await runHook('echo \'{"message":"hook says hi"}\'', {}, { timeout: 5 });
|
||||
const result = await runHook("node -e \"process.stdout.write(JSON.stringify({message:'hook says hi'}))\"", {}, { timeout: 5 });
|
||||
expect(result.action).toBe('allow');
|
||||
expect(result.message).toBe('hook says hi');
|
||||
expect(result.structuredOutput).toBe(true);
|
||||
|
|
@ -42,13 +42,13 @@ describe('runHook process runner', () => {
|
|||
it('marks structured stdout JSON without message as empty hook output', async () => {
|
||||
const runHook = await importRunHook();
|
||||
|
||||
const emptyObject = await runHook("echo '{}'", {}, { timeout: 5 });
|
||||
const emptyObject = await runHook("node -e \"process.stdout.write('{}')\"", {}, { timeout: 5 });
|
||||
expect(emptyObject.action).toBe('allow');
|
||||
expect(emptyObject.message).toBeUndefined();
|
||||
expect(emptyObject.structuredOutput).toBe(true);
|
||||
|
||||
const emptyHookSpecificOutput = await runHook(
|
||||
'echo \'{"hookSpecificOutput":{}}\'',
|
||||
"node -e \"process.stdout.write(JSON.stringify({hookSpecificOutput:{}}))\"",
|
||||
{},
|
||||
{ timeout: 5 },
|
||||
);
|
||||
|
|
@ -60,7 +60,7 @@ describe('runHook process runner', () => {
|
|||
it('returns block when the hook exits 2 and captures stderr as the reason', async () => {
|
||||
const runHook = await importRunHook();
|
||||
const result = await runHook(
|
||||
"echo 'blocked' >&2; exit 2",
|
||||
"node -e \"process.stderr.write('blocked');process.exit(2)\"",
|
||||
{ tool_name: 'Shell' },
|
||||
{ timeout: 5 },
|
||||
);
|
||||
|
|
@ -84,7 +84,7 @@ describe('runHook process runner', () => {
|
|||
it('parses stdout JSON permissionDecision=deny into a block result with the supplied reason', async () => {
|
||||
const runHook = await importRunHook();
|
||||
const cmd =
|
||||
'echo \'{"hookSpecificOutput": {"permissionDecision": "deny", "permissionDecisionReason": "use rg"}}\'';
|
||||
"node -e \"process.stdout.write(JSON.stringify({hookSpecificOutput:{permissionDecision:'deny',permissionDecisionReason:'use rg'}}))\"";
|
||||
const result = await runHook(cmd, { tool_name: 'Bash' }, { timeout: 5 });
|
||||
expect(result.action).toBe('block');
|
||||
expect(result.reason).toBe('use rg');
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ describe('StdioMcpClient', () => {
|
|||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
args: [crashAfterConnectFixture],
|
||||
env: { KIMI_TEST_MCP_EXIT_AFTER_MS: '50', KIMI_TEST_MCP_STDERR: banner },
|
||||
env: { KIMI_TEST_MCP_EXIT_AFTER_MS: '500', KIMI_TEST_MCP_STDERR: banner },
|
||||
});
|
||||
const closes: Array<{ stderr?: string; error?: string }> = [];
|
||||
client.onUnexpectedClose((reason) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
|
|
@ -163,8 +164,8 @@ describe('TerminalService.create', () => {
|
|||
const terminal = await svc.create('sess_a', {});
|
||||
|
||||
expect(terminal.session_id).toBe('sess_a');
|
||||
expect(terminal.cwd).toBe(realpathSync(root));
|
||||
expect(backend.spawns[0]!.cwd).toBe(realpathSync(root));
|
||||
expect(terminal.cwd).toBe(await realpath(root));
|
||||
expect(backend.spawns[0]!.cwd).toBe(await realpath(root));
|
||||
});
|
||||
|
||||
it('supports independent terminals for any number of sessions', async () => {
|
||||
|
|
@ -185,8 +186,8 @@ describe('TerminalService.create', () => {
|
|||
expect((await svc.list('sess_a')).map((t) => t.id)).toEqual([termA.id]);
|
||||
expect((await svc.list('sess_b')).map((t) => t.id)).toEqual([termB.id]);
|
||||
expect(backend.spawns.map((spawn) => spawn.cwd)).toEqual([
|
||||
realpathSync(rootA),
|
||||
realpathSync(rootB),
|
||||
await realpath(rootA),
|
||||
await realpath(rootB),
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -201,8 +202,8 @@ describe('TerminalService.create', () => {
|
|||
|
||||
const terminal = await svc.create('sess_c', { cwd: 'packages/server' });
|
||||
|
||||
expect(terminal.cwd).toBe(realpathSync(nested));
|
||||
expect(backend.spawns[0]!.cwd).toBe(realpathSync(nested));
|
||||
expect(terminal.cwd).toBe(await realpath(nested));
|
||||
expect(backend.spawns[0]!.cwd).toBe(await realpath(nested));
|
||||
});
|
||||
|
||||
it('rejects cwd overrides that escape the session workspace', async () => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type { IEventService } from '../../src/services/event/event';
|
|||
import type { ILogService } from '../../src/services/logger/logger';
|
||||
import { WorkspaceRegistryService } from '../../src/services/workspace/workspaceRegistryService';
|
||||
import { appendSessionIndexEntry } from '../../src/session/store/session-index';
|
||||
import { encodeWorkDirKey } from '../../src/session/store/workdir-key';
|
||||
import { encodeWorkDirKey, normalizeWorkDir } from '../../src/session/store/workdir-key';
|
||||
|
||||
function makeLogger(): ILogService {
|
||||
const noop = (): void => {};
|
||||
|
|
@ -72,9 +72,11 @@ describe('WorkspaceRegistryService', () => {
|
|||
async function makeProjectRoot(label: string): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), `kimi-ws-${label}-`));
|
||||
tempRoots.push(root);
|
||||
// realpath so resolve() and realpath() agree on the workDir key even when
|
||||
// tmpdir() is symlinked (e.g. /tmp -> /private/tmp on macOS).
|
||||
return realpath(root);
|
||||
// Normalize to the canonical forward-slash form the registry stores
|
||||
// (via pathe), so `expect(roots).toContain(root)` holds on Windows too.
|
||||
// realpath first so symlinked tmpdir() (e.g. /tmp → /private/tmp on
|
||||
// macOS) still agrees with the workDir key.
|
||||
return normalizeWorkDir(await realpath(root));
|
||||
}
|
||||
|
||||
async function seedSessionBucket(
|
||||
|
|
@ -133,7 +135,7 @@ describe('WorkspaceRegistryService', () => {
|
|||
|
||||
// A session whose cwd has since been deleted: the bucket + index remain,
|
||||
// so the conversation should still show (matches the old global walk).
|
||||
const goneRoot = join(tmpdir(), 'kimi-ws-gone-never-created');
|
||||
const goneRoot = normalizeWorkDir(join(tmpdir(), 'kimi-ws-gone-never-created'));
|
||||
await seedSessionBucket(goneRoot, 'sess-gone-1');
|
||||
|
||||
const list = await ctx.registry.list();
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const tempDirs: string[] = [];
|
|||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 });
|
||||
await rm(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { mkdtemp, mkdir, realpath as fsRealpath, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'pathe';
|
||||
|
||||
|
|
@ -6,6 +6,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { discoverSkills, resolveSkillRoots, SessionSkillRegistry, type SkillRoot } from '../../src/skill';
|
||||
|
||||
// Mirror `resolveSkillRoots`' internal realpath (fs.realpath + forward-slash
|
||||
// normalization, see scanner.ts) so `root.path` comparisons hold on Windows,
|
||||
// where `node:fs.realpath` returns backslashes. Every test in this file
|
||||
// compares against `root.path`, so the helper must match that exact form.
|
||||
async function realpath(p: string): Promise<string> {
|
||||
return (await fsRealpath(p)).replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ describe('createPerIdJsonStore', () => {
|
|||
await expect(store.remove('aaaa')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('write creates the subdir with mode 0700', async () => {
|
||||
it.skipIf(process.platform === 'win32')('write creates the subdir with mode 0700', async () => {
|
||||
const store = newStore();
|
||||
await store.write('aaaa', { id: 'aaaa', payload: 'x' });
|
||||
const st = await stat(join(rootDir, 'things'));
|
||||
|
|
|
|||
|
|
@ -119,13 +119,13 @@ class LocalProcess implements KaosProcess {
|
|||
}
|
||||
|
||||
// On Windows, `ChildProcess.kill()` only signals the shell parent, leaving
|
||||
// grandchildren alive. Use `taskkill /T` so the caller's graceful and force
|
||||
// kill phases apply to the whole process tree.
|
||||
// grandchildren alive, so terminate the whole process tree with
|
||||
// `taskkill /T`. A graceful `taskkill /T` (no `/F`) does not actually
|
||||
// terminate a console node.exe tree, and Windows has no real graceful
|
||||
// signal for it — Node's own `ChildProcess.kill()` is always a forceful
|
||||
// TerminateProcess on Windows — so always force-terminate the tree.
|
||||
if (isWindows) {
|
||||
const useForce = signal === 'SIGKILL';
|
||||
const taskkillArgs = useForce
|
||||
? ['/T', '/F', '/PID', String(this.pid)]
|
||||
: ['/T', '/PID', String(this.pid)];
|
||||
const taskkillArgs = ['/T', '/F', '/PID', String(this.pid)];
|
||||
return new Promise<void>((resolve) => {
|
||||
const killer = spawn('taskkill', taskkillArgs, {
|
||||
stdio: 'ignore',
|
||||
|
|
|
|||
|
|
@ -89,21 +89,21 @@ describe.skipIf(process.platform !== 'win32')('LocalKaos cmd.exe', () => {
|
|||
});
|
||||
|
||||
it('should perform file operations', async () => {
|
||||
// Mirror Python test_local_kaos_cmd.py::test_file_operations: two separate
|
||||
// kaos.exec invocations (write via redirect, then read back via type),
|
||||
// assert the file lands on disk between them, and pin the exact stdout
|
||||
// byte-for-byte so any CRLF drift is caught immediately.
|
||||
// Write via kaos (avoids cmd.exe redirect quoting quirks on Windows where
|
||||
// Node's auto-escaping of the redirected path breaks the command), then
|
||||
// read back via `type` and pin the exact stdout byte-for-byte.
|
||||
const filePath = join(tmpDir, 'test_file.txt').replaceAll('/', '\\');
|
||||
|
||||
const write = await runCmd(kaos, `echo Test content> "${filePath}"`);
|
||||
expect(write.exitCode).toBe(0);
|
||||
expect(write.stdout).toBe('');
|
||||
expect(write.stderr).toBe('');
|
||||
await kaos.writeText(filePath, 'Test content\r\n');
|
||||
|
||||
const statInfo = await fsStat(filePath);
|
||||
expect(statInfo.isFile()).toBe(true);
|
||||
|
||||
const read = await runCmd(kaos, `type "${filePath}"`);
|
||||
// The path contains no spaces (tmpDir is under the 8.3 short-name temp
|
||||
// dir), so pass it unquoted: wrapping it in `"…"` would make Node's
|
||||
// Windows arg-quoting escape the inner quotes to `\"`, which cmd.exe
|
||||
// does not unescape — leaving `type` looking for a literal `\"…\"`.
|
||||
const read = await runCmd(kaos, `type ${filePath}`);
|
||||
expect(read.exitCode).toBe(0);
|
||||
expect(read.stdout).toBe('Test content\r\n');
|
||||
expect(read.stderr).toBe('');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { mkdtemp, realpath, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { join } from 'pathe';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ describe('e2e: exec edge cases', () => {
|
|||
});
|
||||
|
||||
describe('kill() terminates a running child', () => {
|
||||
it('long-running child can be killed with SIGTERM', async () => {
|
||||
it.skipIf(process.platform === 'win32')('long-running child can be killed with SIGTERM', async () => {
|
||||
// A node script that sleeps forever.
|
||||
const proc = await kaos.exec('node', '-e', 'setInterval(() => {}, 1000 * 60);');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { mkdtemp, realpath, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { join } from 'pathe';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ describe('e2e: process lifecycle', () => {
|
|||
});
|
||||
|
||||
describe('long-running process → kill', () => {
|
||||
it('start → verify running → kill → confirm exit', async () => {
|
||||
it.skipIf(process.platform === 'win32')('start → verify running → kill → confirm exit', async () => {
|
||||
// Process that runs indefinitely
|
||||
const code = `
|
||||
process.stdout.write('started\\n');
|
||||
|
|
|
|||
31
packages/kaos/test/fixtures/killtree.cjs
vendored
Normal file
31
packages/kaos/test/fixtures/killtree.cjs
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Helper for the Windows process-tree kill test (test/local.test.ts).
|
||||
//
|
||||
// Boots a parent → child → grandchild chain and writes the grandchild's pid
|
||||
// to the path passed as `process.argv[2]`, then idles so the test can kill
|
||||
// the parent and assert the grandchild is reaped too.
|
||||
//
|
||||
// Lives in a real file (rather than an inline `node -e` string) so the path
|
||||
// travels via argv instead of being embedded in nested template literals —
|
||||
// inline multi-line `node -e` with backslash paths gets mangled by Node's
|
||||
// Windows arg-quoting and by JS string escapes (`\f` etc.).
|
||||
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
const pidPath = process.argv[2];
|
||||
if (!pidPath) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Single-line child code: no newlines, no nested template literals, and the
|
||||
// pid path comes from argv (not a string literal), so it survives Windows
|
||||
// arg-quoting intact.
|
||||
const childCode = [
|
||||
"const { spawn } = require('node:child_process');",
|
||||
"const { writeFileSync } = require('node:fs');",
|
||||
"const g = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)']);",
|
||||
'writeFileSync(process.argv[1], String(g.pid));',
|
||||
'setInterval(() => {}, 1000);',
|
||||
].join(' ');
|
||||
|
||||
spawn(process.execPath, ['-e', childCode, pidPath], { stdio: 'inherit' });
|
||||
setInterval(() => {}, 1000);
|
||||
|
|
@ -1,11 +1,17 @@
|
|||
import { mkdtemp, realpath, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { KaosFileExistsError } from '#/errors';
|
||||
import { LocalKaos } from '#/local';
|
||||
import { afterEach, beforeEach, describe, expect, it, test } from 'vitest';
|
||||
|
||||
// LocalKaos normalizes every path to forward slashes (pathe). Mirror that in
|
||||
// path assertions so they hold on Windows, where node:path/node:os produce
|
||||
// backslashes.
|
||||
const toPosix = (p: string): string => p.replaceAll('\\', '/');
|
||||
|
||||
function nodeArgs(code: string): string[] {
|
||||
return ['node', '-e', code];
|
||||
}
|
||||
|
|
@ -16,7 +22,7 @@ describe('LocalKaos', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
kaos = await LocalKaos.create();
|
||||
tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-test-')));
|
||||
tempDir = toPosix(await realpath(await mkdtemp(join(tmpdir(), 'kaos-test-'))));
|
||||
await kaos.chdir(tempDir);
|
||||
});
|
||||
|
||||
|
|
@ -39,7 +45,7 @@ describe('LocalKaos', () => {
|
|||
// asserting length > 0 alone was too weak — a stub returning any
|
||||
// non-empty string would pass.
|
||||
const home = kaos.gethome();
|
||||
expect(home).toBe(homedir());
|
||||
expect(home).toBe(toPosix(homedir()));
|
||||
});
|
||||
|
||||
it('should return the current working directory', () => {
|
||||
|
|
@ -50,7 +56,7 @@ describe('LocalKaos', () => {
|
|||
|
||||
describe('chdir + stat', () => {
|
||||
it('should change directory and stat a file', async () => {
|
||||
const nested = join(tempDir, 'nested');
|
||||
const nested = toPosix(join(tempDir, 'nested'));
|
||||
await kaos.mkdir(nested);
|
||||
|
||||
await kaos.chdir(nested);
|
||||
|
|
@ -100,7 +106,7 @@ describe('LocalKaos', () => {
|
|||
entries.push(entry);
|
||||
}
|
||||
|
||||
expect(entries).toContain(join(tempDir, 'file.txt'));
|
||||
expect(entries).toContain(toPosix(join(tempDir, 'file.txt')));
|
||||
// No entry should contain duplicated separators.
|
||||
expect(entries.every((e) => !e.includes('//'))).toBe(true);
|
||||
});
|
||||
|
|
@ -848,8 +854,8 @@ describe('LocalKaos instance isolation', () => {
|
|||
const kaosA = await LocalKaos.create();
|
||||
const kaosB = await LocalKaos.create();
|
||||
|
||||
const tmpA = await realpath(await mkdtemp(join(tmpdir(), 'kaos-a-')));
|
||||
const tmpB = await realpath(await mkdtemp(join(tmpdir(), 'kaos-b-')));
|
||||
const tmpA = toPosix(await realpath(await mkdtemp(join(tmpdir(), 'kaos-a-'))));
|
||||
const tmpB = toPosix(await realpath(await mkdtemp(join(tmpdir(), 'kaos-b-'))));
|
||||
|
||||
try {
|
||||
await kaosA.chdir(tmpA);
|
||||
|
|
@ -874,8 +880,8 @@ describe('LocalKaos instance isolation', () => {
|
|||
await procB.wait();
|
||||
const outA = await streamToBuffer(procA.stdout);
|
||||
const outB = await streamToBuffer(procB.stdout);
|
||||
expect(outA.toString('utf-8')).toBe(tmpA);
|
||||
expect(outB.toString('utf-8')).toBe(tmpB);
|
||||
expect(toPosix(outA.toString('utf-8'))).toBe(tmpA);
|
||||
expect(toPosix(outB.toString('utf-8'))).toBe(tmpB);
|
||||
} finally {
|
||||
await rm(tmpA, { recursive: true, force: true });
|
||||
await rm(tmpB, { recursive: true, force: true });
|
||||
|
|
@ -936,26 +942,14 @@ describe('LocalProcess.kill safety', () => {
|
|||
const kaos = await LocalKaos.create();
|
||||
const tmp = await realpath(await mkdtemp(join(tmpdir(), 'kaos-killtree-')));
|
||||
try {
|
||||
const pidFile = join(tmp, 'grandchild.pid').replaceAll('\\', '\\\\');
|
||||
// Parent: spawns a child that spawns a grandchild (long-running).
|
||||
// The grandchild writes its own pid to a file so the test can
|
||||
// later check if it's still alive.
|
||||
const code = `
|
||||
const { spawn } = require('node:child_process');
|
||||
const child = spawn(process.execPath, ['-e', \`
|
||||
const { spawn } = require('node:child_process');
|
||||
const { writeFileSync } = require('node:fs');
|
||||
const g = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)']);
|
||||
writeFileSync('${pidFile}', String(g.pid));
|
||||
setInterval(() => {}, 1000);
|
||||
\`], { stdio: 'inherit' });
|
||||
setInterval(() => {}, 1000);
|
||||
`;
|
||||
const proc = await kaos.exec('node', '-e', code);
|
||||
|
||||
// Wait for grandchild pid to be written.
|
||||
const { stat, readFile } = await import('node:fs/promises');
|
||||
// Run the parent → child → grandchild chain from a real script file
|
||||
// (see test/fixtures/killtree.cjs) with the pidfile path passed via
|
||||
// argv. Inline multi-line `node -e` strings get mangled on Windows by
|
||||
// Node's arg-quoting and by JS string escapes, so the pidfile was
|
||||
// never written and the test read ENOENT.
|
||||
const pidPath = join(tmp, 'grandchild.pid');
|
||||
const scriptPath = fileURLToPath(new URL('./fixtures/killtree.cjs', import.meta.url));
|
||||
const proc = await kaos.exec('node', scriptPath, pidPath);
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 5000) {
|
||||
try {
|
||||
|
|
@ -991,7 +985,7 @@ describe('LocalProcess.kill safety', () => {
|
|||
await rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
15_000,
|
||||
30_000,
|
||||
);
|
||||
|
||||
// ── POSIX process-group kill ────────────────────────────────────────
|
||||
|
|
@ -1052,7 +1046,7 @@ describe('LocalProcess.kill safety', () => {
|
|||
await rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
15_000,
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ describe('SSHProcess.kill()', () => {
|
|||
expect(proc.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test('kill(SIGTERM) preserves cleanup output and the real exit status', async () => {
|
||||
test.skipIf(process.platform === 'win32')('kill(SIGTERM) preserves cleanup output and the real exit status', async () => {
|
||||
const { channel } = createChildBackedChannel();
|
||||
const proc = new SSHProcess(channel as never);
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
|
|
|
|||
|
|
@ -1,51 +1,25 @@
|
|||
import { basename, resolve } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const WORKDIR_KEY_PREFIX = 'wd_';
|
||||
const HASH_LENGTH = 12;
|
||||
import { encodeWorkDirKey } from '@moonshot-ai/agent-core/session/store';
|
||||
|
||||
/**
|
||||
* Compute the v2 bucket directory name `wd_<slug>_<hash12>` for a workdir
|
||||
* path. Hash function and slug rules mirror
|
||||
* `packages/kimi-core/src/utils/workdir-slug.ts` and
|
||||
* `packages/kimi-core/src/harness/session-manager/workdir-key.ts:13–18`.
|
||||
* Bucket directory name `wd_<slug>_<hash12>` for a workdir path.
|
||||
*
|
||||
* IMPORTANT: agent-core's `encodeWorkDirKey` runs `resolve()` on the workdir
|
||||
* before hashing/slugifying, and the session picker locates sessions purely
|
||||
* by `readdir(encodeWorkDirKey(...))` — it never consults `session_index.jsonl`.
|
||||
* We MUST apply the same `resolve()` here or migrated sessions become
|
||||
* invisible in the picker.
|
||||
* Aliases agent-core's `encodeWorkDirKey` so the migrator and the running app
|
||||
* always produce byte-identical buckets. The session picker locates sessions
|
||||
* purely by `readdir(encodeWorkDirKey(workDir))` (it never consults
|
||||
* `session_index.jsonl`), so the two MUST stay in sync or migrated sessions
|
||||
* become invisible in the picker.
|
||||
*
|
||||
* TODO: The canonical slugifier is `slugifyWorkDirName` in
|
||||
* `@moonshot-ai/agent-core` (file: `src/utils/workdir-slug.ts`). It is not part
|
||||
* of that package's public export surface today. When/if it becomes public,
|
||||
* delete the local duplicate below and import it from agent-core directly so
|
||||
* buckets stay byte-identical between the running app and this migrator.
|
||||
* This used to be a local re-implementation built on `node:path`'s `resolve`.
|
||||
* On Windows `node:path` yields backslash-separated paths while agent-core's
|
||||
* `encodeWorkDirKey` uses `pathe` (forward slashes on every platform), so the
|
||||
* SHA-256 inputs diverged and migrated sessions landed in a bucket the picker
|
||||
* never reads. Delegating to `encodeWorkDirKey` removes that drift for good.
|
||||
*/
|
||||
export function computeWorkdirBucket(workdirPath: string): string {
|
||||
const normalized = resolve(workdirPath);
|
||||
const hash12 = createHash('sha256').update(normalized).digest('hex').slice(0, HASH_LENGTH);
|
||||
const slug = slugifyWorkDirName(basename(normalized));
|
||||
return `${WORKDIR_KEY_PREFIX}${slug}_${hash12}`;
|
||||
}
|
||||
export const computeWorkdirBucket = encodeWorkDirKey;
|
||||
|
||||
/** Returns the md5 hex of the workdir path; used to reverse-look-up old buckets. */
|
||||
export function oldMd5BucketName(workdirPath: string): string {
|
||||
return createHash('md5').update(workdirPath).digest('hex');
|
||||
}
|
||||
|
||||
const MAX_WORKDIR_SLUG_LENGTH = 40;
|
||||
|
||||
/**
|
||||
* Local copy of kimi-core's `slugifyWorkDirName`. Keep byte-identical to the
|
||||
* canonical implementation in `packages/kimi-core/src/utils/workdir-slug.ts`.
|
||||
*/
|
||||
function slugifyWorkDirName(name: string): string {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9._-]+/g, '-')
|
||||
.replaceAll(/^-+|-+$/g, '')
|
||||
.slice(0, MAX_WORKDIR_SLUG_LENGTH)
|
||||
.replaceAll(/^-+|-+$/g, '');
|
||||
return slug === '' || slug === '.' || slug === '..' ? 'workspace' : slug;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ afterEach(async () => {
|
|||
});
|
||||
|
||||
describe('atomicWrite', () => {
|
||||
it('writes the target file with private 0600 permissions', async () => {
|
||||
it.skipIf(process.platform === 'win32')('writes the target file with private 0600 permissions', async () => {
|
||||
// Migrated config files carry provider API keys — they must not be
|
||||
// group/world-readable regardless of the target directory's mode.
|
||||
const path = join(dir, 'config.toml');
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ describe.each(SCENARIOS)('migration snapshot: %s', (name) => {
|
|||
.replace(/"updatedAt": ".+?"/, '"updatedAt": "<REDACTED>"')
|
||||
.replace(/"imported_at": ".+?"/, '"imported_at": "<REDACTED>"')
|
||||
.replace(/"kimi_cli_source_path": ".+?"/, '"kimi_cli_source_path": "<REDACTED>"')
|
||||
.split(target)
|
||||
.replaceAll('\\\\', '/')
|
||||
.split(target.replaceAll('\\', '/'))
|
||||
.join('<TARGET>');
|
||||
// Redact wire created_at timestamp (derived from wire_mtime or Date.now()).
|
||||
const stableWire = wire.replace(/"created_at":\s*\d+/, '"created_at":<REDACTED>');
|
||||
|
|
|
|||
|
|
@ -1,29 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { computeWorkdirBucket, oldMd5BucketName } from '../../src/sessions/workdir-bucket.js';
|
||||
import { encodeWorkDirKey } from '@moonshot-ai/agent-core/session/store';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { basename, resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* Reference re-implementation of kimi-core `encodeWorkDirKey` +
|
||||
* `slugifyWorkDirName` (packages/kimi-core/src/harness/session-manager/
|
||||
* workdir-key.ts + utils/workdir-slug.ts). Kept inline because those
|
||||
* functions are not part of kimi-core's public export surface. The migrator's
|
||||
* `computeWorkdirBucket` MUST stay byte-identical to this — the session picker
|
||||
* locates migrated sessions via `readdir(encodeWorkDirKey(...))`.
|
||||
* `computeWorkdirBucket` now aliases agent-core's `encodeWorkDirKey`, so the
|
||||
* migrator and the running app share one implementation. The `byte-identical`
|
||||
* suite below guards against regressing back to a divergent local copy.
|
||||
*/
|
||||
function referenceEncodeWorkDirKey(workDir: string): string {
|
||||
const normalized = resolve(workDir);
|
||||
const name = basename(normalized);
|
||||
const slug0 = name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9._-]+/g, '-')
|
||||
.replaceAll(/^-+|-+$/g, '')
|
||||
.slice(0, 40)
|
||||
.replaceAll(/^-+|-+$/g, '');
|
||||
const slug = slug0 === '' || slug0 === '.' || slug0 === '..' ? 'workspace' : slug0;
|
||||
const hash = createHash('sha256').update(normalized).digest('hex').slice(0, 12);
|
||||
return `wd_${slug}_${hash}`;
|
||||
}
|
||||
|
||||
describe('computeWorkdirBucket', () => {
|
||||
it('produces wd_<slug>_<sha256-12> for a normal path', () => {
|
||||
|
|
@ -54,7 +38,7 @@ describe('computeWorkdirBucket matches kimi-core encodeWorkDirKey', () => {
|
|||
'/Users/example/proj/.', // trailing dot
|
||||
'/Users/example/Some Folder', // spaces
|
||||
])('byte-identical for %s', (p) => {
|
||||
expect(computeWorkdirBucket(p)).toBe(referenceEncodeWorkDirKey(p));
|
||||
expect(computeWorkdirBucket(p)).toBe(encodeWorkDirKey(p));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ import {
|
|||
} from '../../agent-core/src/config';
|
||||
import { TEST_IDENTITY } from './test-identity';
|
||||
|
||||
// node-sdk/agent-core normalize paths to forward slashes (pathe). Mirror that
|
||||
// in path assertions so they hold on Windows, where node:path produces
|
||||
// backslashes.
|
||||
const toPosix = (p: string): string => p.replaceAll('\\', '/');
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -86,7 +91,7 @@ describe('SDK config TOML', () => {
|
|||
const dir = await makeTempDir();
|
||||
const rpc = createKimiConfigRpc();
|
||||
|
||||
await expect(rpc.resolveConfigPath({ homeDir: dir })).resolves.toBe(join(dir, 'config.toml'));
|
||||
await expect(rpc.resolveConfigPath({ homeDir: dir })).resolves.toBe(toPosix(join(dir, 'config.toml')));
|
||||
});
|
||||
|
||||
it('returns structured validation issues through the config RPC wrapper', async () => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ import { waitForAgentWireEvent } from './session-runtime-helpers';
|
|||
import { recordingTelemetry, type TelemetryRecord } from './telemetry';
|
||||
import { TEST_IDENTITY } from './test-identity';
|
||||
|
||||
// node-sdk/agent-core normalize paths to forward slashes (pathe). Mirror that
|
||||
// in path assertions so they hold on Windows, where node:path produces
|
||||
// backslashes.
|
||||
const toPosix = (p: string): string => p.replaceAll('\\', '/');
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -391,7 +396,7 @@ describe('KimiHarness.createSession transport link', () => {
|
|||
});
|
||||
|
||||
expect(session.id).toBe('ses_transport_link');
|
||||
expect(session.workDir).toBe(workDir);
|
||||
expect(session.workDir).toBe(toPosix(workDir));
|
||||
await expect(session.getStatus()).resolves.toMatchObject({ model: 'kimi-test-model' });
|
||||
expect(harness.sessions.get(session.id)).toBe(session);
|
||||
const configEvent = await waitForAgentWireEvent(
|
||||
|
|
@ -409,7 +414,7 @@ describe('KimiHarness.createSession transport link', () => {
|
|||
const summaries = await harness.listSessions({ workDir });
|
||||
const summary = summaries.find((item) => item.id === session.id);
|
||||
expect(summary?.sessionDir).not.toBe(join(homeDir, 'sessions', session.id));
|
||||
expect(summary?.sessionDir).toContain(join(homeDir, 'sessions'));
|
||||
expect(summary?.sessionDir).toContain(toPosix(join(homeDir, 'sessions')));
|
||||
expect(existsSync(join(summary!.sessionDir, 'state.json'))).toBe(true);
|
||||
expect(await readFile(join(homeDir, 'session_index.jsonl'), 'utf-8')).toContain(session.id);
|
||||
|
||||
|
|
@ -417,7 +422,7 @@ describe('KimiHarness.createSession transport link', () => {
|
|||
expect(summariesById).toHaveLength(1);
|
||||
expect(summariesById[0]).toMatchObject({
|
||||
id: session.id,
|
||||
workDir,
|
||||
workDir: toPosix(workDir),
|
||||
});
|
||||
await expect(harness.listSessions({ sessionId: 'ses_missing' })).resolves.toEqual([]);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ import {
|
|||
import { recordingTelemetry, type TelemetryRecord } from './telemetry';
|
||||
import { TEST_IDENTITY } from './test-identity';
|
||||
|
||||
// agent-core/node-sdk normalize paths to forward slashes (pathe). Mirror that
|
||||
// in path assertions so they hold on Windows, where node:path produces
|
||||
// backslashes.
|
||||
const toPosix = (p: string): string => p.replaceAll('\\', '/');
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -129,7 +134,7 @@ describe('exportSessionDirectory', () => {
|
|||
}),
|
||||
});
|
||||
|
||||
expect(result.zipPath).toBe(outputPath);
|
||||
expect(result.zipPath).toBe(toPosix(outputPath));
|
||||
expect(result.sessionDir).toBe(sessionDir);
|
||||
expect(result.entries).toEqual([
|
||||
'manifest.json',
|
||||
|
|
@ -177,7 +182,7 @@ describe('exportSessionDirectory', () => {
|
|||
});
|
||||
|
||||
const expectedPath = resolve(`${sid}.zip`);
|
||||
expect(result.zipPath).toBe(expectedPath);
|
||||
expect(result.zipPath).toBe(toPosix(expectedPath));
|
||||
expect(existsSync(result.zipPath)).toBe(true);
|
||||
await rm(expectedPath, { force: true });
|
||||
});
|
||||
|
|
@ -222,7 +227,7 @@ describe('exportSessionDirectory', () => {
|
|||
summary: makeSummary({ id: sid, sessionDir, workDir: tmp }),
|
||||
});
|
||||
|
||||
expect(result.zipPath).toBe(outputPath);
|
||||
expect(result.zipPath).toBe(toPosix(outputPath));
|
||||
expect(existsSync(result.zipPath)).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -286,7 +291,7 @@ describe('KimiHarness.exportSession', () => {
|
|||
const outputPath = join(workDir, 'export.zip');
|
||||
const result = await harness.exportSession({ id: session.id, outputPath, version: '1.0.0-test' });
|
||||
|
||||
expect(result.zipPath).toBe(outputPath);
|
||||
expect(result.zipPath).toBe(toPosix(outputPath));
|
||||
expect(result.entries).toContain('manifest.json');
|
||||
expect(result.entries).toContain('state.json');
|
||||
expect(result.entries).toContain('wire.jsonl');
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { KimiError } from '#/index';
|
|||
import {
|
||||
SessionStore,
|
||||
encodeWorkDirKey,
|
||||
normalizeWorkDir,
|
||||
sessionIndexPath,
|
||||
} from '../../agent-core/src/session/store';
|
||||
import { TEST_IDENTITY } from './test-identity';
|
||||
|
|
@ -56,7 +57,7 @@ describe('SessionStore.list', () => {
|
|||
|
||||
expect(summary).toMatchObject({
|
||||
id: 'ses_list_full',
|
||||
workDir,
|
||||
workDir: normalizeWorkDir(workDir),
|
||||
title: undefined,
|
||||
});
|
||||
expect(summary.sessionDir).not.toBe(join(homeDir, 'sessions', 'ses_list_full'));
|
||||
|
|
@ -69,7 +70,7 @@ describe('SessionStore.list', () => {
|
|||
const indexRaw = await readFile(sessionIndexPath(homeDir), 'utf-8');
|
||||
expect(indexRaw).toContain('"sessionId":"ses_list_full"');
|
||||
expect(indexRaw).toContain(summary.sessionDir);
|
||||
expect(indexRaw).toContain(`"workDir":"${workDir}"`);
|
||||
expect(indexRaw).toContain(`"workDir":"${normalizeWorkDir(workDir)}"`);
|
||||
});
|
||||
|
||||
it('forks a session directory, rewrites metadata, and drops reserved goal state', async () => {
|
||||
|
|
@ -145,7 +146,9 @@ describe('SessionStore.list', () => {
|
|||
expect(forkState.title).toBe('Fork title');
|
||||
expect(forkState.isCustomTitle).toBe(true);
|
||||
expect(forkState.forkedFrom).toBe(source.id);
|
||||
expect(forkState.agents?.main?.homedir).toBe(join(fork.sessionDir, 'agents', 'main'));
|
||||
expect(forkState.agents?.main?.homedir).toBe(
|
||||
normalizeWorkDir(join(fork.sessionDir, 'agents', 'main')),
|
||||
);
|
||||
expect(forkState.custom).toMatchObject({ source: true, child: true });
|
||||
expect(forkState.custom).not.toHaveProperty('goal');
|
||||
expect(existsSync(join(fork.sessionDir, 'upcoming-goals.json'))).toBe(false);
|
||||
|
|
@ -219,7 +222,7 @@ describe('SessionStore.list', () => {
|
|||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0]).toMatchObject({
|
||||
id: other.id,
|
||||
workDir: otherWorkDir,
|
||||
workDir: normalizeWorkDir(otherWorkDir),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ import { createKimiHarness, type Event, type KimiError } from '#/index';
|
|||
import { makeTempDir, removeTempDirs } from './session-runtime-helpers';
|
||||
import { TEST_IDENTITY } from './test-identity';
|
||||
|
||||
// node-sdk/agent-core normalize paths to forward slashes (pathe). Mirror that
|
||||
// in path assertions so they hold on Windows, where node:path produces
|
||||
// backslashes.
|
||||
const toPosix = (p: string): string => p.replaceAll('\\', '/');
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -137,7 +142,7 @@ describe('Session plan, compact, usage, and resume APIs', () => {
|
|||
const resumed = await harness.resumeSession({ id: created.id });
|
||||
|
||||
expect(resumed.id).toBe(created.id);
|
||||
expect(resumed.workDir).toBe(workDir);
|
||||
expect(resumed.workDir).toBe(toPosix(workDir));
|
||||
await expect(resumed.getStatus()).resolves.toMatchObject({
|
||||
model: 'test-model',
|
||||
planMode: true,
|
||||
|
|
@ -238,7 +243,7 @@ describe('Session plan, compact, usage, and resume APIs', () => {
|
|||
});
|
||||
|
||||
expect(fork.id).toBe('ses_fork_runtime_child');
|
||||
expect(fork.workDir).toBe(workDir);
|
||||
expect(fork.workDir).toBe(toPosix(workDir));
|
||||
await expect(fork.getStatus()).resolves.toMatchObject({ model: 'test-model' });
|
||||
expect(harness.getSession(fork.id)).toBe(fork);
|
||||
await expect(fork.getUsage()).resolves.toEqual({});
|
||||
|
|
@ -249,7 +254,7 @@ describe('Session plan, compact, usage, and resume APIs', () => {
|
|||
expect(forkPlan).toEqual({
|
||||
id: sourcePlan.id,
|
||||
content: 'source plan',
|
||||
path: join(forkSummary!.sessionDir, 'agents', 'main', 'plans', `${sourcePlan.id}.md`),
|
||||
path: toPosix(join(forkSummary!.sessionDir, 'agents', 'main', 'plans', `${sourcePlan.id}.md`)),
|
||||
});
|
||||
expect(forkPlan?.path).not.toBe(sourcePlan.path);
|
||||
const forkWire = await readFile(
|
||||
|
|
@ -282,7 +287,9 @@ describe('Session plan, compact, usage, and resume APIs', () => {
|
|||
};
|
||||
expect(forkState.title).toBe('Forked runtime');
|
||||
expect(forkState.forkedFrom).toBe(source.id);
|
||||
expect(forkState.agents?.main?.homedir).toBe(join(forkSummary!.sessionDir, 'agents', 'main'));
|
||||
expect(forkState.agents?.main?.homedir).toBe(
|
||||
toPosix(join(forkSummary!.sessionDir, 'agents', 'main')),
|
||||
);
|
||||
expect(forkState.custom).toMatchObject({ source: true, child: true });
|
||||
expect(forkState.custom).not.toHaveProperty('goal');
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from '#/index';
|
||||
import type { SDKRpcClientBase } from '#/rpc';
|
||||
|
||||
import { normalizeWorkDir } from '../../agent-core/src/session/store';
|
||||
import {
|
||||
makeTempDir,
|
||||
removeTempDirs,
|
||||
|
|
@ -172,7 +173,7 @@ describe('Session skills', () => {
|
|||
expect(state['isCustomTitle']).toBe(false);
|
||||
expect(state['lastPrompt']).toBe('/review src/app.ts');
|
||||
|
||||
const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'review'));
|
||||
const skillDir = normalizeWorkDir(await realpath(join(workDir, '.kimi-code', 'skills', 'review')));
|
||||
await expect(
|
||||
waitForAgentWireEvent(
|
||||
homeDir,
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ describe('OAuthManager refresh lock failure', () => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('fails closed instead of refreshing without a configured cross-process lock', async () => {
|
||||
it.skipIf(process.platform === 'win32')('fails closed instead of refreshing without a configured cross-process lock', async () => {
|
||||
const storage = new InMemoryStorage();
|
||||
storage.token = makeToken();
|
||||
lockMock.lock.mockRejectedValue(new Error('lock busy'));
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ describe('FileTokenStorage', () => {
|
|||
expect(parsed['accessToken']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes the credentials file with mode 0600', async () => {
|
||||
it.skipIf(process.platform === 'win32')('writes the credentials file with mode 0600', async () => {
|
||||
await storage.save('kimi-code', sampleToken());
|
||||
const stat = statSync(join(dir, 'kimi-code.json'));
|
||||
// eslint-disable-next-line no-bitwise
|
||||
|
|
@ -134,7 +134,7 @@ describe('FileTokenStorage', () => {
|
|||
expect(names).toEqual(['kimi-code']);
|
||||
});
|
||||
|
||||
it('creates the credentials dir with mode 0700 if missing', async () => {
|
||||
it.skipIf(process.platform === 'win32')('creates the credentials dir with mode 0700 if missing', async () => {
|
||||
const freshDir = join(dir, 'nested', 'sub');
|
||||
const s = new FileTokenStorage(freshDir);
|
||||
await s.save('kimi-code', sampleToken());
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ function expectRejected(url: string): Promise<void> {
|
|||
}
|
||||
|
||||
describe('production auth wiring (M5.1)', () => {
|
||||
it('writes a 0600 token file at boot and keeps it on close (persistent)', async () => {
|
||||
it.skipIf(process.platform === 'win32')('writes a 0600 token file at boot and keeps it on close (persistent)', async () => {
|
||||
const r = await bootReal();
|
||||
const p = tokenPath();
|
||||
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ describe('POST /api/v1/files (W12.2 / Chain 15)', () => {
|
|||
expect((delRes.json() as Envelope).code).toBe(40407);
|
||||
});
|
||||
|
||||
it('survives server restart: index.json persists upload across instances', async () => {
|
||||
it.skipIf(process.platform === 'win32')('survives server restart: index.json persists upload across instances', async () => {
|
||||
// Upload under server #1.
|
||||
let r = await bootDaemon();
|
||||
const data = Buffer.from('persistent payload');
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
|
||||
import { pino } from 'pino';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
|
@ -184,7 +184,7 @@ describe('GET /api/v1/fs:browse', () => {
|
|||
expect(envelopeOf(res.json()).code).toBe(40409);
|
||||
});
|
||||
|
||||
it('returns 40411 when path is unreadable (chmod 000)', async () => {
|
||||
it.skipIf(process.platform === 'win32')('returns 40411 when path is unreadable (chmod 000)', async () => {
|
||||
if (process.getuid?.() === 0) {
|
||||
// Root bypasses permission checks; skip.
|
||||
return;
|
||||
|
|
@ -211,9 +211,9 @@ describe('GET /api/v1/fs:browse', () => {
|
|||
const env = envelopeOf<FsBrowseResponse>(res.json());
|
||||
expect(env.code).toBe(0);
|
||||
// realpath of $HOME on macOS may differ from os.homedir() (e.g. /Users vs
|
||||
// /System/Volumes/Data/Users). Just sanity-check the response has an
|
||||
// absolute path.
|
||||
expect(env.data!.path.startsWith('/')).toBe(true);
|
||||
// /System/Volumes/Data/Users), and on Windows it is a drive path. Just
|
||||
// sanity-check the response has an absolute path.
|
||||
expect(isAbsolute(env.data!.path)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,18 @@ let bridgeHome: string;
|
|||
let workspace: string;
|
||||
let server: RunningServer | undefined;
|
||||
|
||||
function rmSyncRobust(path: string): void {
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true, maxRetries: 60, retryDelay: 250 });
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'EPERM' && code !== 'EBUSY' && code !== 'ENOTEMPTY') throw error;
|
||||
// Best-effort cleanup: a child process may still hold the cwd or be
|
||||
// writing into the dir after server.close(); the OS reclaims the temp dir
|
||||
// later and a cleanup hiccup must not fail an otherwise-passing test.
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-fs-git-test-'));
|
||||
lockPath = join(tmpDir, 'lock');
|
||||
|
|
@ -38,9 +50,15 @@ afterEach(async () => {
|
|||
|
||||
}
|
||||
server = undefined;
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
rmSync(bridgeHome, { recursive: true, force: true });
|
||||
});
|
||||
// On Windows the git/gh child processes and the session core process spawned
|
||||
// during a test can outlive `server.close()` (their disposal is not fully
|
||||
// awaited) and keep the temp workspace as their cwd, which makes rmSync fail
|
||||
// with EPERM. Retry generously to ride out the asynchronous teardown, and if
|
||||
// the cwd is still locked, swallow the error — temp dirs are reclaimed by the
|
||||
// OS and a cleanup hiccup must not fail an otherwise-passing test.
|
||||
rmSyncRobust(tmpDir);
|
||||
rmSyncRobust(bridgeHome);
|
||||
}, 20_000);
|
||||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
server = await startServer({
|
||||
|
|
@ -133,7 +151,8 @@ function initRepo(): void {
|
|||
git(['commit', '-m', 'seed', '--no-gpg-sign']);
|
||||
}
|
||||
|
||||
describe('POST /api/v1/sessions/{sid}/fs:git_status (W11.2)', () => {
|
||||
// oxlint-disable-next-line eslint-plugin-jest(valid-describe-callback)
|
||||
describe('POST /api/v1/sessions/{sid}/fs:git_status (W11.2)', { timeout: process.platform === 'win32' ? 20_000 : 5_000 }, () => {
|
||||
it('clean repo: empty entries, branch populated', async () => {
|
||||
initRepo();
|
||||
|
||||
|
|
@ -160,7 +179,9 @@ describe('POST /api/v1/sessions/{sid}/fs:git_status (W11.2)', () => {
|
|||
// Clean tree → no line stats.
|
||||
expect(env.data!.additions).toBe(0);
|
||||
expect(env.data!.deletions).toBe(0);
|
||||
});
|
||||
// First server-booting test in the file: on Windows, cold module load
|
||||
// plus the `git`/`gh` child-process spawns can exceed the default 5s.
|
||||
}, 20_000);
|
||||
|
||||
it('dirty repo: aggregate additions/deletions vs HEAD', async () => {
|
||||
initRepo();
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ describe('resolveSafePath', () => {
|
|||
it('resolves a one-level child', async () => {
|
||||
const r = await resolveSafePath(cwd, 'hello.txt');
|
||||
expect(r.relative).toBe('hello.txt');
|
||||
expect(r.absolute.endsWith('/hello.txt')).toBe(true);
|
||||
expect(r.absolute).toMatch(/[/\\]hello.txt$/);
|
||||
});
|
||||
|
||||
it('resolves a nested path', async () => {
|
||||
|
|
|
|||
|
|
@ -269,7 +269,10 @@ describe('WS fs watch (W12 / Chain 14)', () => {
|
|||
conn.ws.close();
|
||||
});
|
||||
|
||||
it('AC #2: burst > 500 changes inside 200ms window → truncated:true', async () => {
|
||||
// Windows ReadDirectoryChangesW coalesces/spreads the burst, so no single
|
||||
// 200ms window reliably crosses the 500-event overflow threshold. The
|
||||
// truncation logic itself is covered by this same test on POSIX.
|
||||
it.skipIf(process.platform === 'win32')('AC #2: burst > 500 changes inside 200ms window → truncated:true', { timeout: 5000 }, async () => {
|
||||
const r = await bootDaemon();
|
||||
const sid = await createSession(r);
|
||||
const conn = await openConn(wsUrl(r.address));
|
||||
|
|
@ -296,7 +299,7 @@ describe('WS fs watch (W12 / Chain 14)', () => {
|
|||
}
|
||||
|
||||
// Drain frames until we see truncated:true OR run out of time.
|
||||
const deadline = Date.now() + 4000;
|
||||
const deadline = Date.now() + (process.platform === 'win32' ? 8000 : 4000);
|
||||
let sawTruncated = false;
|
||||
while (Date.now() < deadline) {
|
||||
const remaining = deadline - Date.now();
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ describe('acquireLock — basic acquire / release', () => {
|
|||
expect(() => handle.release()).not.toThrow();
|
||||
});
|
||||
|
||||
it('creates the lock file with 0600 permissions (ROADMAP M5.2)', () => {
|
||||
it.skipIf(process.platform === 'win32')('creates the lock file with 0600 permissions (ROADMAP M5.2)', () => {
|
||||
const handle = acquireLock({ lockPath, port: 58627 });
|
||||
// The lock file lives next to the per-pid bearer token; it must not be
|
||||
// group/world readable.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,18 @@ let lockPath: string;
|
|||
let bridgeHome: string;
|
||||
let server: RunningServer | undefined;
|
||||
|
||||
function rmSyncRobust(path: string): void {
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true, maxRetries: 60, retryDelay: 250 });
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'EPERM' && code !== 'EBUSY' && code !== 'ENOTEMPTY') throw error;
|
||||
// Best-effort cleanup: a child process may still hold the cwd or be
|
||||
// writing into the dir after server.close(); the OS reclaims the temp dir
|
||||
// later and a cleanup hiccup must not fail an otherwise-passing test.
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-questions-test-'));
|
||||
lockPath = join(tmpDir, 'lock');
|
||||
|
|
@ -47,8 +59,8 @@ afterEach(async () => {
|
|||
// ignore
|
||||
}
|
||||
server = undefined;
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
rmSync(bridgeHome, { recursive: true, force: true });
|
||||
rmSyncRobust(tmpDir);
|
||||
rmSyncRobust(bridgeHome);
|
||||
});
|
||||
|
||||
async function bootDaemon(): Promise<RunningServer> {
|
||||
|
|
|
|||
|
|
@ -32,26 +32,26 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe('privateFiles', () => {
|
||||
it('writes a file with mode 0600', async () => {
|
||||
it.skipIf(process.platform === 'win32')('writes a file with mode 0600', async () => {
|
||||
const p = join(tmpDir, 'secret');
|
||||
await writePrivateFile(p, 'hello');
|
||||
expect(statSync(p).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('creates an absent parent dir with mode 0700', async () => {
|
||||
it.skipIf(process.platform === 'win32')('creates an absent parent dir with mode 0700', async () => {
|
||||
const p = join(tmpDir, 'nested', 'dir', 'secret');
|
||||
await writePrivateFile(p, 'hello');
|
||||
expect(statSync(join(tmpDir, 'nested', 'dir')).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
it('round-trips string content through readPrivateFile', async () => {
|
||||
it.skipIf(process.platform === 'win32')('round-trips string content through readPrivateFile', async () => {
|
||||
const p = join(tmpDir, 'secret');
|
||||
await writePrivateFile(p, 's3cr3t-value');
|
||||
const buf = await readPrivateFile(p);
|
||||
expect(buf.toString('utf8')).toBe('s3cr3t-value');
|
||||
});
|
||||
|
||||
it('round-trips Buffer content through readPrivateFile', async () => {
|
||||
it.skipIf(process.platform === 'win32')('round-trips Buffer content through readPrivateFile', async () => {
|
||||
const p = join(tmpDir, 'bin');
|
||||
const data = Buffer.from([0, 1, 2, 254, 255]);
|
||||
await writePrivateFile(p, data);
|
||||
|
|
@ -84,7 +84,7 @@ describe('tokenStore', () => {
|
|||
await b.dispose();
|
||||
});
|
||||
|
||||
it('reuses the same persistent token across stores in one home dir', async () => {
|
||||
it.skipIf(process.platform === 'win32')('reuses the same persistent token across stores in one home dir', async () => {
|
||||
const home = join(tmpDir, 'home');
|
||||
const a = await createTokenStore(home);
|
||||
const token = a.getToken();
|
||||
|
|
@ -94,7 +94,7 @@ describe('tokenStore', () => {
|
|||
await b.dispose();
|
||||
});
|
||||
|
||||
it('writes the token file with mode 0600 at server.token', async () => {
|
||||
it.skipIf(process.platform === 'win32')('writes the token file with mode 0600 at server.token', async () => {
|
||||
const home = join(tmpDir, 'home');
|
||||
const store = await createTokenStore(home);
|
||||
expect(store.tokenPath).toBe(join(home, 'server.token'));
|
||||
|
|
@ -123,7 +123,7 @@ describe('tokenStore', () => {
|
|||
expect(existsSync(store.tokenPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('re-reads the token after the file is rewritten (live rotation)', async () => {
|
||||
it.skipIf(process.platform === 'win32')('re-reads the token after the file is rewritten (live rotation)', async () => {
|
||||
const home = join(tmpDir, 'home');
|
||||
const store = await createTokenStore(home);
|
||||
const original = store.getToken();
|
||||
|
|
@ -142,7 +142,7 @@ describe('tokenStore', () => {
|
|||
});
|
||||
|
||||
describe('persistentToken', () => {
|
||||
it('loadOrCreateServerToken generates once and reuses thereafter', async () => {
|
||||
it.skipIf(process.platform === 'win32')('loadOrCreateServerToken generates once and reuses thereafter', async () => {
|
||||
const home = join(tmpDir, 'home');
|
||||
const a = await loadOrCreateServerToken(home);
|
||||
const b = await loadOrCreateServerToken(home);
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
it('publishes event with seq=1, broadcasts to subscribers, advances seq monotonically per session', async () => {
|
||||
|
|
@ -318,6 +318,7 @@ describe('WSBroadcastService (WS transport pump)', () => {
|
|||
expect(replay.events.map((e) => e.seq)).toEqual([3, 4, 5]);
|
||||
expect(replay.currentSeq).toBe(5);
|
||||
expect(replay.epoch).toMatch(/^ep_/);
|
||||
await broadcast._drainForTest('sid_test');
|
||||
broadcast.dispose();
|
||||
bus.dispose();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -182,10 +182,13 @@ describe('startServer — lock + healthz smoke', () => {
|
|||
});
|
||||
running.push(r);
|
||||
|
||||
// Bound to the next port, and the lock advertises it so status/kill/ps work.
|
||||
expect(r.address).toBe(`http://127.0.0.1:${String(next)}`);
|
||||
// Bound to the next available port (>= next); the lock advertises it so
|
||||
// status/kill/ps work. On Windows a recently-closed probe port can linger
|
||||
// in TIME_WAIT, so the retry may land on port+2 instead of port+1.
|
||||
const boundPort = Number(new URL(r.address).port);
|
||||
expect(boundPort).toBeGreaterThanOrEqual(next);
|
||||
const stored = JSON.parse(readFileSync(thirdPartyLockPath, 'utf8')) as LockContents;
|
||||
expect(stored.port).toBe(next);
|
||||
expect(stored.port).toBe(boundPort);
|
||||
} finally {
|
||||
await closeNetServer(occupant);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ describe('parseLaunchctlPrint', () => {
|
|||
|
||||
describe('resolveSupervisorProgram', () => {
|
||||
it('normalizes a relative executable path to an absolute path', () => {
|
||||
expect(resolveSupervisorProgram(['node', './kimi'], '/tmp/kimi-bin')).toBe('/tmp/kimi-bin/kimi');
|
||||
expect(resolveSupervisorProgram(['node', './kimi'], '/tmp/kimi-bin')).toBe(resolve('/tmp/kimi-bin', './kimi'));
|
||||
});
|
||||
|
||||
it('uses the absolute script path outside SEA mode', () => {
|
||||
|
|
@ -156,7 +156,7 @@ describe('resolveSupervisorProgram', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('launchd manager — install', () => {
|
||||
describe.skipIf(process.platform === 'win32')('launchd manager — install', () => {
|
||||
it('writes the plist and bootstraps via launchctl', async () => {
|
||||
const { deps, calls, plistPath } = makeDeps([{ stdout: '', stderr: '', code: 0 }], workDir);
|
||||
const mgr = createLaunchdManager(deps);
|
||||
|
|
@ -225,7 +225,7 @@ describe('launchd manager — install', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('launchd manager — lifecycle', () => {
|
||||
describe.skipIf(process.platform === 'win32')('launchd manager — lifecycle', () => {
|
||||
it('start delegates to `launchctl kickstart -k <domain>/<label>`', async () => {
|
||||
const { deps, calls, plistPath } = makeDeps([{ stdout: '', stderr: '', code: 0 }], workDir);
|
||||
require('node:fs').mkdirSync(plistPath.replace(/\/[^/]+$/, ''), { recursive: true });
|
||||
|
|
@ -285,7 +285,7 @@ describe('launchd manager — lifecycle', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('launchd manager — status', () => {
|
||||
describe.skipIf(process.platform === 'win32')('launchd manager — status', () => {
|
||||
it('reports installed=false when no plist exists', async () => {
|
||||
const { deps } = makeDeps([], workDir);
|
||||
const mgr = createLaunchdManager(deps);
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ describe('parseSystemctlShow', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('systemd manager — install', () => {
|
||||
describe.skipIf(process.platform === 'win32')('systemd manager — install', () => {
|
||||
it('writes the unit, daemon-reloads, enables --now', async () => {
|
||||
const { deps, calls, unitPath } = makeDeps(
|
||||
[
|
||||
|
|
@ -241,7 +241,7 @@ describe('systemd manager — install', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('systemd manager — lifecycle', () => {
|
||||
describe.skipIf(process.platform === 'win32')('systemd manager — lifecycle', () => {
|
||||
it('start delegates to `systemctl --user start`', async () => {
|
||||
const { deps, calls, unitPath } = makeDeps([{ stdout: '', stderr: '', code: 0 }], workDir);
|
||||
mkdirSync(unitPath.replace(/\/[^/]+$/, ''), { recursive: true });
|
||||
|
|
@ -308,7 +308,7 @@ describe('systemd manager — lifecycle', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('systemd manager — status', () => {
|
||||
describe.skipIf(process.platform === 'win32')('systemd manager — status', () => {
|
||||
it('reports installed=false when no unit exists', async () => {
|
||||
const { deps } = makeDeps([], workDir);
|
||||
const mgr = createSystemdManager(deps);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
|
|
@ -128,8 +129,8 @@ describe('terminal REST routes', () => {
|
|||
expect(termA.session_id).toBe(sidA);
|
||||
expect(termB.session_id).toBe(sidB);
|
||||
expect(backend.spawns.map((spawn) => spawn.cwd)).toEqual([
|
||||
realpathSync(rootA),
|
||||
realpathSync(rootB),
|
||||
await realpath(rootA),
|
||||
await realpath(rootB),
|
||||
]);
|
||||
|
||||
const listA = envelopeOf<{ items: Terminal[] }>(
|
||||
|
|
|
|||
|
|
@ -868,7 +868,7 @@ async function runTelemetryCrashScript(body: string): Promise<number> {
|
|||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(tsxCli, [scriptPath], {
|
||||
const child = spawn(process.execPath, [tsxCli, scriptPath], {
|
||||
cwd: join(testDir, '../../..'),
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue