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
|
- name: Check consumer type surface
|
||||||
run: pnpm check:consumer-types
|
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
|
# 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
|
# through the official `dsh plugin --profile web add` channel, and render
|
||||||
# the DSH web UI headlessly (Playwright Chromium) to prove the plugin
|
# the DSH web UI headlessly (Playwright Chromium) to prove the plugin
|
||||||
|
|||||||
@@ -10,6 +10,13 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
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
|
# The check reads the BUILT declaration surface (lib/types) — fail loudly
|
||||||
# instead of silently passing when the build output is missing.
|
# instead of silently passing when the build output is missing.
|
||||||
if [ ! -f lib/types/client/service.d.ts ]; then
|
if [ ! -f lib/types/client/service.d.ts ]; then
|
||||||
@@ -21,11 +28,27 @@ WORK="$(mktemp -d)"
|
|||||||
trap 'rm -rf "$WORK"' EXIT
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
mkdir -p "$WORK/node_modules"
|
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)
|
# The vendored cordis base (and, through its realpath, cosmokit / standard-schema)
|
||||||
# must resolve so the consumer can type `Context` against the DSH runtime scope.
|
# must resolve so the consumer can type `Context` against the DSH runtime scope.
|
||||||
mkdir -p "$WORK/node_modules/@deepseek-ai"
|
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'
|
cat > "$WORK/check.ts" <<'EOF'
|
||||||
import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from 'dsh-better-sidebar/client/service'
|
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) }
|
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
|
* Best-effort resize for WebSocket-driven terminal views. Layout animation
|
||||||
* can briefly produce unusable dimensions, and node-pty can reject a resize
|
* 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
|
* 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.
|
* 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(
|
export function tryResizePty(
|
||||||
pty: Pick<IPty, 'resize'>,
|
pty: Pick<IPty, 'resize'>,
|
||||||
@@ -54,6 +100,11 @@ export function tryResizePty(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false
|
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false
|
||||||
const dims = clampDims(cols, rows)
|
const dims = clampDims(cols, rows)
|
||||||
|
const gate = ptyResizeGates.get(pty)
|
||||||
|
if (gate !== undefined && !gate.sawData) {
|
||||||
|
gate.pending = dims
|
||||||
|
return true
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
pty.resize(dims.cols, dims.rows)
|
pty.resize(dims.cols, dims.rows)
|
||||||
return true
|
return true
|
||||||
@@ -249,6 +300,7 @@ export class AgentPtyRegistry {
|
|||||||
cwd,
|
cwd,
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
})
|
})
|
||||||
|
armPtyResizeGate(pty)
|
||||||
const handle: AgentTerminalHandle = {
|
const handle: AgentTerminalHandle = {
|
||||||
uuid,
|
uuid,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -376,7 +428,11 @@ export class AgentPtyRegistry {
|
|||||||
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
|
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
|
||||||
const handle = this.expect(uuid)
|
const handle = this.expect(uuid)
|
||||||
const dims = clampDims(cols, rows)
|
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
|
return dims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -42,7 +42,7 @@ import { launchExternal } from './open-external.ts'
|
|||||||
import * as git from './git.ts'
|
import * as git from './git.ts'
|
||||||
import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
|
import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
|
||||||
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts'
|
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 {
|
import {
|
||||||
DSH_NODE_PTY_RANGE,
|
DSH_NODE_PTY_RANGE,
|
||||||
depsStatus,
|
depsStatus,
|
||||||
@@ -1209,6 +1209,9 @@ async function attachTerminal(
|
|||||||
// terminals opened from now on (existing pty handles keep their shell).
|
// terminals opened from now on (existing pty handles keep their shell).
|
||||||
const overrides = shellOverridesOf(getSettings)
|
const overrides = shellOverridesOf(getSettings)
|
||||||
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs)
|
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.
|
// Replay the transcript, then follow live output.
|
||||||
if (handle.transcript !== '') ws.send(handle.transcript)
|
if (handle.transcript !== '') ws.send(handle.transcript)
|
||||||
const onData = (data: string): void => {
|
const onData = (data: string): void => {
|
||||||
|
|||||||
@@ -84,7 +84,12 @@ describe('/sidebar/bundle route', () => {
|
|||||||
try {
|
try {
|
||||||
const first = fakeRes()
|
const first = fakeRes()
|
||||||
await handler(req('GET', '/sidebar/bundle/editor.js'), first as unknown as ServerResponse)
|
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()
|
const second = fakeRes()
|
||||||
await handler(req('GET', '/sidebar/bundle/editor.js', { 'if-none-match': first.headers.etag! }), second as unknown as ServerResponse)
|
await handler(req('GET', '/sidebar/bundle/editor.js', { 'if-none-match': first.headers.etag! }), second as unknown as ServerResponse)
|
||||||
expect(second.status).toBe(200)
|
expect(second.status).toBe(200)
|
||||||
|
|||||||
@@ -68,8 +68,11 @@ describe('linked Git worktrees', () => {
|
|||||||
git(main, ['worktree', 'add', '-q', '-b', 'agent', agent])
|
git(main, ['worktree', 'add', '-q', '-b', 'agent', agent])
|
||||||
writeFileSync(join(agent, 'tracked.txt'), 'changed by agent\n')
|
writeFileSync(join(agent, 'tracked.txt'), 'changed by agent\n')
|
||||||
// macOS tmpdir() keeps the /var symlink while git reports the resolved
|
// macOS tmpdir() keeps the /var symlink while git reports the resolved
|
||||||
// /private/var prefix — canonicalize every path handed to git APIs.
|
// /private/var prefix; on Windows TEMP is often the 8.3 short form
|
||||||
const agentPath = realpathSync(agent)
|
// (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)
|
const listed = await worktrees(main)
|
||||||
expect(listed).toHaveLength(2)
|
expect(listed).toHaveLength(2)
|
||||||
@@ -87,7 +90,7 @@ describe('linked Git worktrees', () => {
|
|||||||
rmSync(agent, { recursive: true, force: true })
|
rmSync(agent, { recursive: true, force: true })
|
||||||
const remaining = await worktrees(main)
|
const remaining = await worktrees(main)
|
||||||
expect(remaining).toHaveLength(1)
|
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)
|
expect(remaining[0]!.current).toBe(true)
|
||||||
await expect(resolveWorktree(main, agentPath)).rejects.toThrow('unknown linked worktree')
|
await expect(resolveWorktree(main, agentPath)).rejects.toThrow('unknown linked worktree')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+6
-2
@@ -11,8 +11,12 @@ import { parseLogLines, parsePorcelainZ, repoRoots, status } from '../src/git.ts
|
|||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
const normalizePath = (path: string): string => path.replaceAll('\\', '/')
|
const normalizePath = (path: string): string => path.replaceAll('\\', '/')
|
||||||
// macOS tmpdir() is the /var symlink while git reports the resolved
|
// macOS tmpdir() is the /var symlink while git reports the resolved
|
||||||
// /private/var prefix — canonicalize both sides before comparing.
|
// /private/var prefix; on Windows TEMP is often the 8.3 short form
|
||||||
const canonical = (path: string): string => normalizePath(realpathSync(path))
|
// (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', () => {
|
describe('git parsing', () => {
|
||||||
it('discovers and selects direct child repositories under a workspace directory', async () => {
|
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
|
// spawn directly (ENOENT). Run it through the shell so the same call
|
||||||
// works on every platform; `tar` is available on all supported OSes.
|
// 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('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>
|
return JSON.parse(readFileSync(join(dir, 'package/package.json'), 'utf8')) as Record<string, unknown>
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(dir, { recursive: true, force: true })
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
|||||||
@@ -409,6 +409,12 @@ describe('git destructive operations (scratch repository)', () => {
|
|||||||
const makeScratchRepo = (): string => {
|
const makeScratchRepo = (): string => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-git-'))
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-git-'))
|
||||||
gitRun(dir, ['init', '-q'])
|
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'])
|
gitRun(dir, ['checkout', '-q', '-b', 'main'])
|
||||||
writeFileSync(join(dir, 'a.txt'), 'one\ntwo\nthree\n')
|
writeFileSync(join(dir, 'a.txt'), 'one\ntwo\nthree\n')
|
||||||
gitRun(dir, ['add', '-A'])
|
gitRun(dir, ['add', '-A'])
|
||||||
|
|||||||
Reference in New Issue
Block a user