Merge pull request #520 from omdsh-dev/feat/ci-windows

ci: add a Windows lane for the unit/typecheck/build suite
This commit is contained in:
Atlas Zeng
2026-09-03 10:47:53 +08:00
committed by GitHub
9 changed files with 158 additions and 12 deletions
+43
View File
@@ -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
+25 -2
View File
@@ -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'
+58 -2
View File
@@ -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<object, ResizeGateState>()
/**
* 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<IPty, 'resize'>,
@@ -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
}
+4 -1
View File
@@ -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 => {
+6 -1
View File
@@ -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)
+6 -3
View File
@@ -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 {
+6 -2
View File
@@ -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 () => {
+4 -1
View File
@@ -57,7 +57,10 @@ function packedManifest(): Record<string, unknown> {
// 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<string, unknown>
} finally {
rmSync(dir, { recursive: true, force: true })
+6
View File
@@ -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'])