diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a119c37..f10c662 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,49 @@ jobs: - name: Check consumer type surface run: pnpm check:consumer-types + # Windows parity lane for the pure-logic suite. The codebase carries real + # win32 branches (shell parsing, WSL path projection, the PowerShell + # installer) that mock-injection tests alone cannot validate — this lane + # runs the same typecheck / test / build / consumer-type-surface steps on + # a real Windows runtime. The plugin-mount lane (real DSH + Playwright + # Chromium) stays ubuntu-only and is deliberately NOT mirrored here. + ci-windows: + runs-on: windows-latest + timeout-minutes: 30 + defaults: + run: + # The runner ships Git Bash: multi-line steps and + # scripts/check-consumer-types.sh work verbatim. + shell: bash + steps: + - uses: actions/checkout@v7 + with: + # Tests run git log against this repository and need the full history. + fetch-depth: 0 + - uses: pnpm/action-setup@v6 + with: + version: 11 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + # Same rationale as the ci job: smoke tests spawn git without an + # identity and rely on ambient config, which the runner lacks. + - name: Configure git identity for tests + run: | + git config --global user.name "dsh-better-sidebar-ci" + git config --global user.email "ci@dsh.invalid" + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Typecheck + run: pnpm typecheck + - name: Test + run: pnpm test + - name: Build + run: pnpm build + - name: Check consumer type surface + run: pnpm check:consumer-types + # Pack the plugin as an npm tarball, mount it into a REAL DSH instance # through the official `dsh plugin --profile web add` channel, and render # the DSH web UI headlessly (Playwright Chromium) to prove the plugin diff --git a/scripts/check-consumer-types.sh b/scripts/check-consumer-types.sh index 2e3f22e..51b9ccd 100755 --- a/scripts/check-consumer-types.sh +++ b/scripts/check-consumer-types.sh @@ -10,6 +10,13 @@ set -euo pipefail cd "$(dirname "$0")/.." +# Git Bash (MSYS) `ln -s` deep-copies directories by default — copying this +# repo (node_modules + full .git history) would be catastrophic. Force real +# native symlinks instead: the GitHub Windows runners (Developer Mode, admin +# user) can create them, and nativestrict makes ln fail loudly rather than +# silently falling back to a copy. Inert on Linux/macOS. +export MSYS="winsymlinks:nativestrict" + # The check reads the BUILT declaration surface (lib/types) — fail loudly # instead of silently passing when the build output is missing. if [ ! -f lib/types/client/service.d.ts ]; then @@ -21,11 +28,27 @@ WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT mkdir -p "$WORK/node_modules" -ln -s "$(pwd)" "$WORK/node_modules/dsh-better-sidebar" +# Resolve link targets to their PHYSICAL path first: under pnpm, +# node_modules/@deepseek-ai/cordis is itself a symlink/junction into .pnpm, +# and on Windows a native symlink pointing AT a junction does not traverse +# for tsc (TS2307 on a link that visibly exists). Node's NATIVE realpath +# (GetFinalPathNameByHandle on Windows) expands junctions, which bash-level +# resolution may leave untouched; node is guaranteed here (tsc runs below). +resolve_dir() { node -e 'process.stdout.write(require("node:fs").realpathSync.native(process.argv[1]))' "$1"; } +ln -s "$(resolve_dir "$(pwd)")" "$WORK/node_modules/dsh-better-sidebar" # The vendored cordis base (and, through its realpath, cosmokit / standard-schema) # must resolve so the consumer can type `Context` against the DSH runtime scope. mkdir -p "$WORK/node_modules/@deepseek-ai" -ln -s "$(pwd)/node_modules/@deepseek-ai/cordis" "$WORK/node_modules/@deepseek-ai/cordis" +ln -s "$(resolve_dir "$(pwd)/node_modules/@deepseek-ai/cordis")" "$WORK/node_modules/@deepseek-ai/cordis" +# Loud guard: both links must resolve to a package root, or tsc below would +# report misleading module-resolution errors. +for pkg_json in "$WORK/node_modules/dsh-better-sidebar/package.json" "$WORK/node_modules/@deepseek-ai/cordis/package.json"; do + if [ ! -f "$pkg_json" ]; then + echo "[check-consumer-types] FAIL: $pkg_json does not resolve (broken link). Link layer:" >&2 + ls -la "$WORK/node_modules" "$WORK/node_modules/@deepseek-ai" >&2 || true + exit 1 + fi +done cat > "$WORK/check.ts" <<'EOF' import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from 'dsh-better-sidebar/client/service' diff --git a/src/agent-pty.ts b/src/agent-pty.ts index e7a0e72..561bfc2 100644 --- a/src/agent-pty.ts +++ b/src/agent-pty.ts @@ -40,12 +40,58 @@ export function clampDims(cols: number, rows: number): { cols: number; rows: num return { cols: clamp(cols), rows: clamp(rows) } } +/** + * Per-pty Windows resize-gate state (see {@link armPtyResizeGate}). + */ +interface ResizeGateState { + /** Whether the pty has produced any output yet (ConPTY flush completed). */ + sawData: boolean + /** Latest dims requested before the first output; replayed once it arrives. */ + pending?: { cols: number; rows: number } +} + +/** + * node-pty's Windows terminal queues resize calls that arrive before the + * ConPTY control socket's first data flush (`_deferNoArgs` in + * windowsTerminal.js). If the pty exits before the queue flushes, the + * deferred resize throws inside the socket's 'data' handler — uncatchable + * by any caller and fatal to the host process. POSIX terminals have no such + * queue (resize is synchronous), so the gate is armed on Windows only: + * {@link tryResizePty} parks dims requested before the first output and + * replays them after the flush, when node-pty executes resizes + * synchronously (and the throw for an exited pty is catchable). + */ +const ptyResizeGates = new WeakMap() + +/** + * Arm the Windows pre-ready resize gate for one freshly spawned pty. + * No-op on POSIX and for injected ptys without `onData`. + */ +export function armPtyResizeGate(pty: IPty): void { + if (process.platform !== 'win32') return + if (typeof pty.onData !== 'function' || ptyResizeGates.has(pty)) return + const state: ResizeGateState = { sawData: false } + ptyResizeGates.set(pty, state) + pty.onData(() => { + if (state.sawData) return + state.sawData = true + const dims = state.pending + state.pending = undefined + if (dims === undefined) return + // node-pty's deferred flush shares this very 'data' dispatch; hop past + // it so the replayed resize executes synchronously (catchable) instead + // of being queued into the same deferred list we are draining. + setImmediate(() => { tryResizePty(pty, dims.cols, dims.rows) }) + }) +} + /** * Best-effort resize for WebSocket-driven terminal views. Layout animation * can briefly produce unusable dimensions, and node-pty can reject a resize * after the socket setup's outer try/catch has returned. Ignore that one * frame so the host stays alive and a later valid measurement can retry. - * Returns whether node-pty accepted the resize. + * Returns whether node-pty accepted the resize (or parked it for replay on + * the first output — the Windows pre-ready window). */ export function tryResizePty( pty: Pick, @@ -54,6 +100,11 @@ export function tryResizePty( ): boolean { if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false const dims = clampDims(cols, rows) + const gate = ptyResizeGates.get(pty) + if (gate !== undefined && !gate.sawData) { + gate.pending = dims + return true + } try { pty.resize(dims.cols, dims.rows) return true @@ -249,6 +300,7 @@ export class AgentPtyRegistry { cwd, env: { ...process.env }, }) + armPtyResizeGate(pty) const handle: AgentTerminalHandle = { uuid, sessionId, @@ -376,7 +428,11 @@ export class AgentPtyRegistry { resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } { const handle = this.expect(uuid) const dims = clampDims(cols, rows) - if (!handle.exited) handle.pty.resize(dims.cols, dims.rows) + // Through tryResizePty: node-pty throws on a just-exited pty (the exit + // event may not have flipped `handle.exited` yet), and on Windows a + // pre-ready resize must go through the gate instead of node-pty's own + // deferred queue (which flushes uncatchably inside its socket handler). + if (!handle.exited) tryResizePty(handle.pty, dims.cols, dims.rows) return dims } diff --git a/src/index.ts b/src/index.ts index e87ff4b..85c53d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,7 +42,7 @@ import { launchExternal } from './open-external.ts' import * as git from './git.ts' import { SettingsConflictError } from '@deepseek-ai/dsh-settings' import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts' -import { AgentPtyRegistry, tryResizePty, type AgentTerminalHandle } from './agent-pty.ts' +import { AgentPtyRegistry, armPtyResizeGate, tryResizePty, type AgentTerminalHandle } from './agent-pty.ts' import { DSH_NODE_PTY_RANGE, depsStatus, @@ -1209,6 +1209,9 @@ async function attachTerminal( // terminals opened from now on (existing pty handles keep their shell). const overrides = shellOverridesOf(getSettings) const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs) + // Windows pre-ready gate for the resize frames this socket may deliver + // (see armPtyResizeGate; inert on POSIX). + armPtyResizeGate(handle.pty) // Replay the transcript, then follow live output. if (handle.transcript !== '') ws.send(handle.transcript) const onData = (data: string): void => { diff --git a/tests/bundle-route.spec.ts b/tests/bundle-route.spec.ts index 1585cd5..501585d 100644 --- a/tests/bundle-route.spec.ts +++ b/tests/bundle-route.spec.ts @@ -84,7 +84,12 @@ describe('/sidebar/bundle route', () => { try { const first = fakeRes() await handler(req('GET', '/sidebar/bundle/editor.js'), first as unknown as ServerResponse) - writeFileSync(join(dir, 'client-editor.js'), 'window.__ModuleLoader__ && 1;') + // The rewrite must change the LENGTH too: the ETag memo revalidates on + // stat (mtime+size), and Windows updates file timestamps lazily — an + // equal-size rewrite right after the first read can still look + // unchanged (stale 304). A size delta busts the memo deterministically + // on every platform. + writeFileSync(join(dir, 'client-editor.js'), 'window.__ModuleLoader__ && 1; // rotated') const second = fakeRes() await handler(req('GET', '/sidebar/bundle/editor.js', { 'if-none-match': first.headers.etag! }), second as unknown as ServerResponse) expect(second.status).toBe(200) diff --git a/tests/git-worktree.spec.ts b/tests/git-worktree.spec.ts index 2f4aca3..c0b4e2e 100644 --- a/tests/git-worktree.spec.ts +++ b/tests/git-worktree.spec.ts @@ -68,8 +68,11 @@ describe('linked Git worktrees', () => { git(main, ['worktree', 'add', '-q', '-b', 'agent', agent]) writeFileSync(join(agent, 'tracked.txt'), 'changed by agent\n') // macOS tmpdir() keeps the /var symlink while git reports the resolved - // /private/var prefix — canonicalize every path handed to git APIs. - const agentPath = realpathSync(agent) + // /private/var prefix; on Windows TEMP is often the 8.3 short form + // (C:\Users\RUNNER~1\...) while git reports the long path. The NATIVE + // realpath resolves both aliasings (libuv's GetFinalPathNameByHandle + // expands 8.3 names) — canonicalize every path handed to git APIs. + const agentPath = realpathSync.native(agent) const listed = await worktrees(main) expect(listed).toHaveLength(2) @@ -87,7 +90,7 @@ describe('linked Git worktrees', () => { rmSync(agent, { recursive: true, force: true }) const remaining = await worktrees(main) expect(remaining).toHaveLength(1) - expect(resolve(remaining[0]!.path)).toBe(resolve(realpathSync(main))) + expect(resolve(remaining[0]!.path)).toBe(resolve(realpathSync.native(main))) expect(remaining[0]!.current).toBe(true) await expect(resolveWorktree(main, agentPath)).rejects.toThrow('unknown linked worktree') } finally { diff --git a/tests/git.spec.ts b/tests/git.spec.ts index 9932d23..b5fccc6 100644 --- a/tests/git.spec.ts +++ b/tests/git.spec.ts @@ -11,8 +11,12 @@ import { parseLogLines, parsePorcelainZ, repoRoots, status } from '../src/git.ts const execFileAsync = promisify(execFile) const normalizePath = (path: string): string => path.replaceAll('\\', '/') // macOS tmpdir() is the /var symlink while git reports the resolved -// /private/var prefix — canonicalize both sides before comparing. -const canonical = (path: string): string => normalizePath(realpathSync(path)) +// /private/var prefix; on Windows TEMP is often the 8.3 short form +// (C:\Users\RUNNER~1\...) while git reports the long path. The NATIVE +// realpath resolves both aliasings (libuv canonicalizes on Windows via +// GetFinalPathNameByHandle, which expands 8.3 names) — canonicalize both +// sides before comparing. +const canonical = (path: string): string => normalizePath(realpathSync.native(path)) describe('git parsing', () => { it('discovers and selects direct child repositories under a workspace directory', async () => { diff --git a/tests/market-manifest.spec.ts b/tests/market-manifest.spec.ts index 945e459..0c30693 100644 --- a/tests/market-manifest.spec.ts +++ b/tests/market-manifest.spec.ts @@ -57,7 +57,10 @@ function packedManifest(): Record { // spawn directly (ENOENT). Run it through the shell so the same call // works on every platform; `tar` is available on all supported OSes. execFileSync('pnpm', ['pack', '--pack-destination', dir], { cwd: ROOT, stdio: 'pipe', shell: process.platform === 'win32' }) - execFileSync('tar', ['-xzf', join(dir, `${pkg.name}-${pkg.version}.tgz`), '-C', dir, 'package/package.json'], { stdio: 'pipe' }) + // Never hand tar an absolute Windows path: Git Bash's GNU tar reads the + // drive-letter colon as a remote-host spec ("Cannot connect to C:"). + // Run it from the pack dir with the bare tarball name instead. + execFileSync('tar', ['-xzf', `${pkg.name}-${pkg.version}.tgz`, 'package/package.json'], { cwd: dir, stdio: 'pipe' }) return JSON.parse(readFileSync(join(dir, 'package/package.json'), 'utf8')) as Record } finally { rmSync(dir, { recursive: true, force: true }) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 990edad..99a834d 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -409,6 +409,12 @@ describe('git destructive operations (scratch repository)', () => { const makeScratchRepo = (): string => { const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-git-')) gitRun(dir, ['init', '-q']) + // Pin the eol policy: Git for Windows defaults to core.autocrlf=true + // (system gitconfig on the CI runner, and many dev machines), which + // smudges LF→CRLF on every index restore and breaks the byte-exact + // assertions below. The destructive-op behavior under test is orthogonal + // to the machine's eol policy. + gitRun(dir, ['config', 'core.autocrlf', 'false']) gitRun(dir, ['checkout', '-q', '-b', 'main']) writeFileSync(join(dir, 'a.txt'), 'one\ntwo\nthree\n') gitRun(dir, ['add', '-A'])