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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user