diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1af3e130c07..7b9fa2678f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -507,7 +507,31 @@ jobs: QWEN_DEFAULT_AUTH_TYPE: '' run: |- node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }" + # ENOSPC has failed test steps mid-suite while the host looks + # healthy afterwards — a transient spike, likely /tmp inodes or a + # tmpfs cap. Sample the routed temp filesystem every 10s so the + # failing run captures the spike, and dump full state on failure. + # Keep Linux temp paths real and short on disk-backed /var/tmp. + # Symlink aliases break tests that intentionally compare real paths. + export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" + if [ "${RUNNER_OS:-}" = "Linux" ]; then + QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX 2>/dev/null || true)" + if [ -n "$QWEN_CI_TMPDIR" ]; then + TMPDIR="$QWEN_CI_TMPDIR" + export TMPDIR + trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT + fi + fi + ( while true; do echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) & + SAMPLER_PID=$! + set +e npm run test:ci + RC=$? + set -e + pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true + kill "$SAMPLER_PID" 2>/dev/null || true + if [ "$RC" -ne 0 ]; then df -hT 2>/dev/null || df -h 2>/dev/null || true; df -i 2>/dev/null || true; grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>/dev/null || true; fi + exit "$RC" # Release guard for the Chrome extension: build, package, and scan real # artifacts for forbidden adapter signatures instead of leaving @@ -805,7 +829,31 @@ jobs: QWEN_DEFAULT_AUTH_TYPE: '' run: |- node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }" + # ENOSPC has failed test steps mid-suite while the host looks + # healthy afterwards — a transient spike, likely /tmp inodes or a + # tmpfs cap. Sample the routed temp filesystem every 10s so the + # failing run captures the spike, and dump full state on failure. + # Keep Linux temp paths real and short on disk-backed /var/tmp. + # Symlink aliases break tests that intentionally compare real paths. + export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" + if [ "${RUNNER_OS:-}" = "Linux" ]; then + QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX 2>/dev/null || true)" + if [ -n "$QWEN_CI_TMPDIR" ]; then + TMPDIR="$QWEN_CI_TMPDIR" + export TMPDIR + trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT + fi + fi + ( while true; do echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) & + SAMPLER_PID=$! + set +e npm run test:ci + RC=$? + set -e + pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true + kill "$SAMPLER_PID" 2>/dev/null || true + if [ "$RC" -ne 0 ]; then df -hT 2>/dev/null || df -h 2>/dev/null || true; df -i 2>/dev/null || true; grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>/dev/null || true; fi + exit "$RC" # Windows counterpart of test_macos (see that job's note). ECS is the default # with a windows-2022 kill-switch fallback; the check name stays unchanged so @@ -911,7 +959,31 @@ jobs: QWEN_DEFAULT_AUTH_TYPE: '' run: |- node -e "const fs = require('node:fs'); for (const key of ['HOME', 'USERPROFILE']) { const dir = process.env[key]; if (dir) fs.mkdirSync(dir, { recursive: true }); }" + # ENOSPC has failed test steps mid-suite while the host looks + # healthy afterwards — a transient spike, likely /tmp inodes or a + # tmpfs cap. Sample the routed temp filesystem every 10s so the + # failing run captures the spike, and dump full state on failure. + # Keep Linux temp paths real and short on disk-backed /var/tmp. + # Symlink aliases break tests that intentionally compare real paths. + export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" + if [ "${RUNNER_OS:-}" = "Linux" ]; then + QWEN_CI_TMPDIR="$(mktemp -d /var/tmp/qwen-ci-XXXXXX 2>/dev/null || true)" + if [ -n "$QWEN_CI_TMPDIR" ]; then + TMPDIR="$QWEN_CI_TMPDIR" + export TMPDIR + trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT + fi + fi + ( while true; do echo "DFSAMPLE $(date -u +%H:%M:%S 2>/dev/null) tmpdir[${TMPDIR}] space[$(df -h "${TMPDIR}" 2>/dev/null | tail -1)] inodes[$(df -i "${TMPDIR}" 2>/dev/null | tail -1)] memavail[$(awk '/MemAvailable/ {print $2, $3}' /proc/meminfo 2>/dev/null)]" 2>/dev/null; sleep 10; done ) & + SAMPLER_PID=$! + set +e npm run test:ci + RC=$? + set -e + pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true + kill "$SAMPLER_PID" 2>/dev/null || true + if [ "$RC" -ne 0 ]; then df -hT 2>/dev/null || df -h 2>/dev/null || true; df -i 2>/dev/null || true; grep -E 'MemTotal|MemAvailable|SwapTotal|SwapFree' /proc/meminfo 2>/dev/null || true; fi + exit "$RC" post_coverage_comment: name: 'Post Coverage Comment' diff --git a/.qwen/review-context.json b/.qwen/review-context.json index f3c553bb593..a2927fac016 100644 --- a/.qwen/review-context.json +++ b/.qwen/review-context.json @@ -21,7 +21,6 @@ }, { "paths": ["packages/core/src/skills/**"], - "relatedPaths": ["packages/core/src/skills/**"], "domains": ["core-skills"] }, { diff --git a/packages/cli/src/commands/review/lib/manifest-repository-context.committed.test.ts b/packages/cli/src/commands/review/lib/manifest-repository-context.committed.test.ts index 0b22dd7c11f..25c2944585c 100644 --- a/packages/cli/src/commands/review/lib/manifest-repository-context.committed.test.ts +++ b/packages/cli/src/commands/review/lib/manifest-repository-context.committed.test.ts @@ -47,7 +47,6 @@ const expectedManifest = { }, { paths: ['packages/core/src/skills/**'], - relatedPaths: ['packages/core/src/skills/**'], domains: ['core-skills'], }, { @@ -95,8 +94,6 @@ const expectedManifest = { const relatedPathSentinels: Readonly> = { 'packages/core/src/config/**': 'packages/core/src/config/config.ts', - 'packages/core/src/skills/**': - 'packages/core/src/skills/bundled/review/SKILL.md', 'packages/web-shell/client/adapters/**': 'packages/web-shell/client/adapters/types.ts', 'packages/web-shell/client/completions/**': diff --git a/packages/cli/src/commands/review/lib/manifest-repository-context.test.ts b/packages/cli/src/commands/review/lib/manifest-repository-context.test.ts index 8d40883d271..eca79e5d95f 100644 --- a/packages/cli/src/commands/review/lib/manifest-repository-context.test.ts +++ b/packages/cli/src/commands/review/lib/manifest-repository-context.test.ts @@ -15,7 +15,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { afterAll, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { manifestRepositoryContextProvider, MAX_GLOB_CANDIDATES, @@ -23,7 +23,7 @@ import { } from './manifest-repository-context.js'; import { MAX_IDENTITY_BYTES } from './repository-context.js'; -const worktrees: string[] = []; +let worktrees: string[] = []; function temp(): string { const root = realpathSync(mkdtempSync(join(tmpdir(), 'manifest-context-'))); @@ -31,15 +31,19 @@ function temp(): string { return root; } -// Several fixtures hold 16k-entry trees; leaking them exhausts a tmpfs -// /tmp within a handful of runs. Deleting them is tens of thousands of -// unlinks, which has blown past the default 10s hook timeout on a loaded -// CI runner — give the teardown the time it needs rather than failing a -// green suite on cleanup. -afterAll(() => { +// Several fixtures hold 16k-entry trees, and the skip-set suite stacks ten +// of them — holding every tree until afterAll keeps ~164k files (inodes) +// alive for the whole file, which on a shared self-hosted host coincides +// with concurrent jobs' temp files and has surfaced as ENOSPC mid-suite. +// Tear down per test instead so at most one 16k tree is live at a time. +// Deleting one tree is tens of thousands of unlinks, which has blown past +// the default 10s hook timeout on a loaded CI runner — give the teardown +// the time it needs rather than failing a green suite on cleanup. +afterEach(() => { for (const root of worktrees) { rmSync(root, { recursive: true, force: true }); } + worktrees = []; }, 120_000); function write(path: string, content = ''): void { diff --git a/packages/cli/src/serve/bridge-file-system-adapter.test.ts b/packages/cli/src/serve/bridge-file-system-adapter.test.ts index d6330c6f3cd..d264350820f 100644 --- a/packages/cli/src/serve/bridge-file-system-adapter.test.ts +++ b/packages/cli/src/serve/bridge-file-system-adapter.test.ts @@ -700,17 +700,20 @@ describe('createBridgeFileSystemAdapter', () => { it('rejects a Unix socket as an external text target', async () => { if (process.platform === 'win32') return; - const socketPath = path.join(outsideDir, 'target.sock'); - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(socketPath, resolve); - }); - const adapter = createBridgeFileSystemAdapter( - buildFactory({ trusted: true }), - { allowSameHostToolWritesOutsideWorkspace: true }, + const socketDir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'bridge-fs-socket-'), ); + const socketPath = path.join(socketDir, 'target.sock'); + const server = createServer(); try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + const adapter = createBridgeFileSystemAdapter( + buildFactory({ trusted: true }), + { allowSameHostToolWritesOutsideWorkspace: true }, + ); await expect( adapter.writeText({ path: socketPath, @@ -722,6 +725,7 @@ describe('createBridgeFileSystemAdapter', () => { expect(auditEmits).toHaveLength(1); } finally { await new Promise((resolve) => server.close(() => resolve())); + await fsp.rm(socketDir, { recursive: true, force: true }); } }); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index e845e773045..8ae903f9384 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -25,7 +25,7 @@ import request from 'supertest'; import { WebSocket } from 'ws'; import { trace, type Span } from '@opentelemetry/api'; import { - createServeApp, + createServeApp as createServeAppImpl, computeKeepaliveIntervalMs, detectFromLoopback, listWorkspaceSessionsForResponse, @@ -292,6 +292,38 @@ const baseOpts: ServeOptions = { mode: 'http-bridge', }; +// Direct app tests bypass runQwenServe's reconciler cleanup. +const createdApps = new Set>(); + +function createServeApp(...args: Parameters) { + const app = createServeAppImpl(...args); + createdApps.add(app); + return app; +} + +function stopCreatedApps() { + for (const app of createdApps) { + ( + app.locals as { stopExtensionGenerationReconciler?: () => void } + ).stopExtensionGenerationReconciler?.(); + } + createdApps.clear(); +} + +afterEach(stopCreatedApps); + +it('stops extension generation reconcilers for direct app tests', () => { + const stopExtensionGenerationReconciler = vi.fn(); + createdApps.add({ + locals: { stopExtensionGenerationReconciler }, + } as ReturnType); + + stopCreatedApps(); + + expect(stopExtensionGenerationReconciler).toHaveBeenCalledOnce(); + expect(createdApps.size).toBe(0); +}); + function fakeDaemonLog(): DaemonLogger { return { info: vi.fn(), @@ -364,7 +396,11 @@ afterAll(async () => { restoreEnv('QWEN_HOME', previousServerTestQwenHome); restoreEnv('QWEN_RUNTIME_DIR', previousServerTestRuntimeDir); resetHomeEnvBootstrapForTesting(); - await fsp.rm(serverTestEnvironmentRoot, { recursive: true, force: true }); + await fsp.rm(serverTestEnvironmentRoot, { + recursive: true, + force: true, + maxRetries: 3, + }); }); function deferred(): { @@ -26809,7 +26845,6 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { } it('parks a single-workspace registry on app.locals for the canonical primary workspace', async () => { - const { createServeApp } = await import('./server.js'); const app = createServeApp( { port: 0, @@ -26850,7 +26885,6 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { }); it('parks a default WorkspaceFileSystemFactory on app.locals when none is injected', async () => { - const { createServeApp } = await import('./server.js'); const app = createServeApp( { port: 0, @@ -26872,7 +26906,6 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { }); it('uses the injected fsFactory verbatim when supplied', async () => { - const { createServeApp } = await import('./server.js'); const sentinel = { forRequest: vi.fn(() => ({ marker: 'injected' })) }; const app = createServeApp( { @@ -26895,7 +26928,6 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { }); it('threads production-style primary trust into the default runtime metadata', async () => { - const { createServeApp } = await import('./server.js'); const app = createServeApp( { port: 0, @@ -26911,7 +26943,6 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { }); it('threads primary runtime env metadata into the default registry runtime', async () => { - const { createServeApp } = await import('./server.js'); const primaryRuntimeEnv = { mode: 'runtime-overlay', overlayKeys: ['OPENAI_API_KEY'], @@ -26933,7 +26964,6 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { }); it('uses an injected workspace registry as the primary runtime source', async () => { - const { createServeApp } = await import('./server.js'); const runtime = makeInjectedWorkspaceRuntime(); const registry = createWorkspaceRegistry([runtime]); @@ -27006,7 +27036,6 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { }); it('accepts matching runtime deps when a workspace registry is injected', async () => { - const { createServeApp } = await import('./server.js'); const runtime = makeInjectedWorkspaceRuntime(); const registry = createWorkspaceRegistry([runtime]); @@ -27030,8 +27059,6 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { }); it('requires the Voice coordinator paired with runtime removal', async () => { - const { createServeApp } = await import('./server.js'); - expect(() => createServeApp( { @@ -27048,8 +27075,6 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { }); it('requires a live bridge provider when runtime generations can change', async () => { - const { createServeApp } = await import('./server.js'); - expect(() => createServeApp( { @@ -27066,7 +27091,6 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { }); it('uses the injected registry sender when client-MCP over WS is enabled', async () => { - const { createServeApp } = await import('./server.js'); const runtime = makeInjectedWorkspaceRuntime(); const registry = createWorkspaceRegistry([runtime]); @@ -27122,7 +27146,6 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { }); it('rejects conflicting runtime deps when a workspace registry is injected', async () => { - const { createServeApp } = await import('./server.js'); const runtime = makeInjectedWorkspaceRuntime(); const registry = createWorkspaceRegistry([runtime]); @@ -27246,7 +27269,6 @@ describe('createServeApp ServeAppDeps.fsFactory wiring (#4175 PR 18)', () => { }); it('default fsFactory is built with trusted=false (writes refused)', async () => { - const { createServeApp } = await import('./server.js'); const { isFsError } = await import('./fs/index.js'); const os = await import('node:os'); const tmp = await import('node:fs').then((m) => diff --git a/packages/cli/src/ui/hooks/useStatusLine.test.ts b/packages/cli/src/ui/hooks/useStatusLine.test.ts index 75aafb93339..7bb083dcb66 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.test.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.test.ts @@ -4,19 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeAll, - beforeEach, - afterEach, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import * as child_process from 'child_process'; import { StreamingState } from '../types.js'; import type { StatusLinePresetReasoning } from '../statusLinePresets.js'; +import { useStatusLine } from './useStatusLine.js'; const debugLogMock = vi.hoisted(() => ({ log: vi.fn(), @@ -157,14 +150,6 @@ function setStatusLineConfig( } describe('useStatusLine', () => { - // Must import dynamically after mocks are set up - let useStatusLine: typeof import('./useStatusLine.js').useStatusLine; - - beforeAll(async () => { - const mod = await import('./useStatusLine.js'); - useStatusLine = mod.useStatusLine; - }, 20_000); - beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 48a6aced98a..21ad36fe76d 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -144,6 +144,10 @@ export default defineConfig({ // vitest's 5s default so I/O-bound tests (e.g. the workspace registration // store's tempdir round-trip) don't blow it purely under CI contention. testTimeout: 15000, + // ECS hosts run several jobs at once; leave capacity for neighboring jobs. + maxWorkers: process.env['RUNNER_NAME']?.startsWith('ecs-qwen-') + ? '25%' + : undefined, include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)', 'config.test.ts'], exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**'], environment: 'jsdom', @@ -168,12 +172,6 @@ export default defineConfig({ ['json-summary', { outputFile: 'coverage-summary.json' }], ], }, - poolOptions: { - threads: { - minThreads: 8, - maxThreads: 16, - }, - }, server: { deps: { inline: [/@qwen-code\/qwen-code-core/], diff --git a/packages/core/src/agents/runtime/workflow-sandbox.test.ts b/packages/core/src/agents/runtime/workflow-sandbox.test.ts index d4a7762b15f..73037a81fa4 100644 --- a/packages/core/src/agents/runtime/workflow-sandbox.test.ts +++ b/packages/core/src/agents/runtime/workflow-sandbox.test.ts @@ -1126,6 +1126,7 @@ describe('createWorkflowSandbox security', () => { // never re-armed would leave a script hung in ungated code pending // forever (no settlement, snapshot, or telemetry). The abort must // re-arm the banked remainder. + vi.useFakeTimers(); const scheduler = new WorkflowDispatchScheduler(1); const abortOnTimeout = new AbortController(); let finish: ((value: string) => void) | undefined; @@ -1139,24 +1140,31 @@ describe('createWorkflowSandbox security', () => { scheduler, abortOnTimeout, }); - // Consume most of the budget BEFORE pausing so the banked remainder - // (~80 ms) is distinguishable from a fresh full budget (200 ms). const run = sandbox.run(`await agent('a'); return new Promise(() => {});`); - await vi.waitFor(() => expect(finish).toBeDefined()); - await new Promise((resolve) => setTimeout(resolve, 120)); - expect(scheduler.pause()).toBe(true); - finish?.('done'); - await vi.waitFor(() => expect(scheduler.snapshot().state).toBe('paused')); - - abortOnTimeout.abort(); - const rearmAt = Date.now(); - await expect(run).rejects.toThrow(/exceeded 200 ms of active time/); - // The banked remainder (~80 ms) bounds the settle: pre-fix the race - // never settled at all, a fresh-budget re-arm would overshoot the - // upper bound, and a zero-remainder re-arm would fire before the - // lower bound. - expect(Date.now() - rearmAt).toBeLessThan(150); - expect(Date.now() - rearmAt).toBeGreaterThan(40); + let settled = false; + const settlement = run.catch(() => { + settled = true; + }); + try { + await vi.advanceTimersByTimeAsync(0); + expect(finish).toBeDefined(); + await vi.advanceTimersByTimeAsync(120); + expect(scheduler.pause()).toBe(true); + finish?.('done'); + await vi.advanceTimersByTimeAsync(0); + expect(scheduler.snapshot().state).toBe('paused'); + + abortOnTimeout.abort(); + await vi.advanceTimersByTimeAsync(20); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(100); + await expect(run).rejects.toThrow(/exceeded 200 ms of active time/); + } finally { + abortOnTimeout.abort(); + await vi.runAllTimersAsync(); + await settlement; + vi.useRealTimers(); + } }); it('keeps the watchdog armed when a post-abort drain lands paused', async () => { diff --git a/packages/core/src/tools/ls.test.ts b/packages/core/src/tools/ls.test.ts index 8a657a7382b..56439099d59 100644 --- a/packages/core/src/tools/ls.test.ts +++ b/packages/core/src/tools/ls.test.ts @@ -14,6 +14,7 @@ import type { Config } from '../config/config.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { ToolErrorType } from './tool-error.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; +import { shortenPath } from '../utils/paths.js'; describe('LSTool', () => { let lsTool: LSTool; @@ -410,7 +411,7 @@ describe('LSTool', () => { }; const invocation = lsTool.build(params); const description = invocation.getDescription(); - const expected = path.resolve(params.path); + const expected = shortenPath(path.resolve(params.path)); expect(description).toBe(expected); }); }); diff --git a/packages/core/src/utils/shell-ast-parser-lazy.test.ts b/packages/core/src/utils/shell-ast-parser-lazy.test.ts index 20faea09232..0db182c768f 100644 --- a/packages/core/src/utils/shell-ast-parser-lazy.test.ts +++ b/packages/core/src/utils/shell-ast-parser-lazy.test.ts @@ -11,11 +11,16 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { createRequire } from 'node:module'; import { build, type Metafile, type Plugin } from 'esbuild'; import { afterEach, describe, expect, it, vi } from 'vitest'; +// Keep Vite's one-time transform outside the individual test timeout. +import './shellAstParser.js'; + +vi.resetModules(); const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)); const tempDirs: string[] = []; afterEach(() => { + vi.restoreAllMocks(); vi.doUnmock('web-tree-sitter'); vi.resetModules(); for (const dir of tempDirs.splice(0)) { @@ -23,6 +28,17 @@ afterEach(() => { } }); +function mockWasmReads(): void { + const nativeFs = process.getBuiltinModule('fs'); + const readFile = nativeFs.readFileSync; + vi.spyOn(nativeFs, 'readFileSync').mockImplementation((( + ...args: Parameters + ) => + String(args[0]).endsWith('.wasm') + ? Buffer.from([0]) + : Reflect.apply(readFile, nativeFs, args)) as typeof readFile); +} + function wasmBinaryPlugin(): Plugin { return { name: 'wasm-binary-test', @@ -84,17 +100,11 @@ function expectDeferredInput( describe('shellAstParser lazy runtime', () => { it('loads web-tree-sitter on first use and deduplicates initialization', async () => { + mockWasmReads(); const runtimeLoaded = vi.fn(); const init = vi.fn(async () => undefined); - let releaseLanguage!: () => void; - const languageReady = new Promise((resolve) => { - releaseLanguage = resolve; - }); const constructed = vi.fn(); - const loadLanguage = vi.fn(async () => { - await languageReady; - return {}; - }); + const loadLanguage = vi.fn(async () => ({})); class ParserMock { static init = init; @@ -115,17 +125,7 @@ describe('shellAstParser lazy runtime', () => { const parser = await import('./shellAstParser.js'); expect(runtimeLoaded).not.toHaveBeenCalled(); const first = parser.initParser(); - await vi.waitFor(() => expect(loadLanguage).toHaveBeenCalledTimes(1)); - expect(constructed).not.toHaveBeenCalled(); - - let secondResolved = false; - const second = parser.initParser().then(() => { - secondResolved = true; - }); - await Promise.resolve(); - expect(secondResolved).toBe(false); - - releaseLanguage(); + const second = parser.initParser(); await Promise.all([first, second]); expect(runtimeLoaded).toHaveBeenCalledTimes(1); expect(init).toHaveBeenCalledTimes(1); @@ -134,6 +134,7 @@ describe('shellAstParser lazy runtime', () => { }); it('latches a language load failure', async () => { + mockWasmReads(); const languageLoads = vi.fn(async () => { throw new Error('bash language unavailable'); }); @@ -160,6 +161,7 @@ describe('shellAstParser lazy runtime', () => { }); it('maps parser runtime exceptions without changing the legacy fallback', async () => { + mockWasmReads(); const init = vi.fn(async () => undefined); const deleteParser = vi.fn(); const parse = vi.fn(() => { @@ -205,6 +207,7 @@ describe('shellAstParser lazy runtime', () => { }); it('releases each parsed tree exactly once', async () => { + mockWasmReads(); const deleteTree = vi.fn(); const parse = vi .fn() diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 85a298b6504..b8e90013bac 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -9,11 +9,15 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { // Raise the per-test ceiling above vitest's 5s default: the self-hosted - // CI runners are heavily oversubscribed (maxThreads: 16 below), and I/O- + // CI runners are heavily oversubscribed, and I/O- // or WASM-load-bound tests (e.g. the web-tree-sitter lazy runtime, tar // extraction) blow 5s purely under contention, not from any logic fault. // Assertions still fail instantly; only the timeout ceiling grows. testTimeout: 15000, + // ECS hosts run several jobs at once; leave capacity for neighboring jobs. + maxWorkers: process.env['RUNNER_NAME']?.startsWith('ecs-qwen-') + ? '25%' + : undefined, reporters: ['default', 'junit'], silent: true, setupFiles: ['./test-setup.ts'], @@ -34,11 +38,5 @@ export default defineConfig({ ['json-summary', { outputFile: 'coverage-summary.json' }], ], }, - poolOptions: { - threads: { - minThreads: 8, - maxThreads: 16, - }, - }, }, }); diff --git a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx index 293be347600..e93d5867d9f 100644 --- a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx +++ b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx @@ -150,6 +150,15 @@ async function mount({ }; } +async function waitForImageIngestion() { + await vi.waitFor(async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(latest!.pendingImageBatchCount).toBe(0); + }); +} + afterEach(() => { act(() => root?.unmount()); vi.useRealTimers(); @@ -794,9 +803,7 @@ describe('useComposerCore paste', () => { expect(onSubmit).not.toHaveBeenCalled(); expect(latest!.pendingImageBatchCount).toBe(1); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); + await waitForImageIngestion(); expect(latest!.pendingImageBatchCount).toBe(0); expect(latest!.pastedImages).toMatchObject([{ media_type: 'image/png' }]); @@ -875,9 +882,7 @@ describe('useComposerCore paste', () => { drop([first, unsupported]); drop([second]); }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 30)); - }); + await waitForImageIngestion(); expect(latest!.pastedImages.map((image) => image.media_type)).toEqual([ 'image/bmp', @@ -910,9 +915,7 @@ describe('useComposerCore paste', () => { drop([new File(['text'], 'notes.txt', { type: 'text/plain' })]); drop([new File(['png'], 'photo.png', { type: 'image/png' })]); }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 30)); - }); + await waitForImageIngestion(); expect(onImageIngestionNotice).toHaveBeenCalledOnce(); expect(latest!.pastedImages).toMatchObject([{ media_type: 'image/png' }]); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 6abe27c44d3..202d9e09374 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -14517,6 +14517,7 @@ describe('DaemonSessionProvider', () => { }); const sourceReady = createDeferred(); const sourceEvent = createDeferred(); + const sourceEventProcessed = createDeferred(); let sourceSignal: AbortSignal | undefined; let subscriptions = 0; const source = createMockSession({ @@ -14551,6 +14552,7 @@ describe('DaemonSessionProvider', () => { }, }, }; + sourceEventProcessed.resolve(); await new Promise((resolve) => opts.signal?.addEventListener('abort', () => resolve(), { once: true, @@ -14597,7 +14599,7 @@ describe('DaemonSessionProvider', () => { }); await act(async () => { sourceEvent.resolve(); - await flushPromises(); + await sourceEventProcessed.promise; await flushTranscriptDispatch(); }); expect( diff --git a/scripts/tests/no-ak-integration-ci.test.js b/scripts/tests/no-ak-integration-ci.test.js index 2efbbb9b9fd..077932d7185 100644 --- a/scripts/tests/no-ak-integration-ci.test.js +++ b/scripts/tests/no-ak-integration-ci.test.js @@ -4,7 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -19,6 +27,104 @@ const NODE_ACTION_PATH = '.github/actions/self-hosted-node/action.yml'; const GUARD_STEP = 'Verify checkout includes expected head commit'; describe('no-AK integration CI wiring', () => { + it.runIf(process.platform === 'linux')( + 'keeps Linux Unix socket paths short and identity-stable', + () => { + const workflow = readFileSync( + path.join(ROOT, '.github/workflows/ci.yml'), + 'utf8', + ); + const routingBlocks = ['test', 'test_macos', 'test_windows'].map( + (jobName) => { + const testStep = getWorkflowStep( + getWorkflowJob(workflow, jobName), + 'Run tests and generate reports', + ); + const start = testStep.indexOf('export TMPDIR='); + expect( + start, + `${jobName}: TMPDIR routing block`, + ).toBeGreaterThanOrEqual(0); + const end = testStep.indexOf('\n ( while true', start); + expect(end, `${jobName}: sampler sentinel`).toBeGreaterThan(start); + return testStep.slice(start, end); + }, + ); + expect(new Set(routingBlocks)).toHaveLength(1); + const [routeTemp] = routingBlocks; + const root = mkdtempSync(path.join(tmpdir(), 'ci-temp-routing-')); + const longRunnerTemp = path.join(root, 'x'.repeat(180)); + + try { + mkdirSync(longRunnerTemp); + const [routedTemp, resolvedTemp] = execFileSync( + 'bash', + [ + '-c', + `${routeTemp}\nprintf '%s\\n%s\\n' "$TMPDIR" "$(cd "$TMPDIR" && pwd -P)"`, + ], + { + encoding: 'utf8', + env: { + ...process.env, + RUNNER_OS: 'Linux', + RUNNER_TEMP: longRunnerTemp, + }, + }, + ) + .trim() + .split('\n'); + + expect(resolvedTemp).toBe(routedTemp); + expect(routedTemp).toMatch(/^\/var\/tmp\/qwen-ci-/); + expect(existsSync(routedTemp)).toBe(false); + expect( + Buffer.byteLength( + path.join(routedTemp, 'qwen-agent-view-XXXXXX', 'supervisor.sock'), + ), + ).toBeLessThan(108); + expect( + workflow.match(/mktemp -d \/var\/tmp\/qwen-ci-XXXXXX/g), + ).toHaveLength(3); + expect(workflow).toContain('QWEN_CI_TMPDIR="$(mktemp -d'); + expect(workflow).toContain('if [ -n "$QWEN_CI_TMPDIR" ]; then'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it('preserves test failures in every wrapped OS job', () => { + const workflow = readFileSync( + path.join(ROOT, '.github/workflows/ci.yml'), + 'utf8', + ); + + for (const jobName of ['test', 'test_macos', 'test_windows']) { + const testStep = getWorkflowStep( + getWorkflowJob(workflow, jobName), + 'Run tests and generate reports', + ); + expect(testStep).toContain( + 'trap \'rm -rf "$TMPDIR" 2>/dev/null || true\' EXIT', + ); + let previous = -1; + for (const command of [ + 'set +e', + 'npm run test:ci', + 'RC=$?', + 'set -e', + 'pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true', + 'kill "$SAMPLER_PID" 2>/dev/null || true', + 'exit "$RC"', + ]) { + const index = testStep.indexOf(command); + expect(index, `${jobName}: ${command}`).toBeGreaterThan(previous); + previous = index; + } + } + }); + it('defines a focused no-AK integration script', () => { const packageJson = JSON.parse( readFileSync(path.join(ROOT, 'package.json'), 'utf8'), diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index 02b428e3d45..fd6d1341b2c 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -12295,27 +12295,13 @@ describe('run-agent idle watchdog', () => { expect(r.agentLog.length).toBe(2_097_152); }); - it('ignores a non-positive or non-numeric QWEN_IDLE_TIMEOUT_MS instead of arming it', () => { - // Number('-1') is truthy, so a bare `|| default` guard would arm a - // negative window: Date.now() - lastOutputAt >= -1 is instantly true - // and every agent dies at the first idle tick. `0` (an operator's - // "disable") arms a zero-length window that is true at the first tick, - // and NaN arms one too — every rejection class named in the parse - // guard's comment must fall back to the default. - for (const idleMs of [-1, 0, Number.NaN]) { - const r = runAgent({ - stub: [ - '#!/bin/bash', - 'for i in $(seq 1 8); do echo "tick $i"; sleep 0.4; done', - 'echo summary > "${AGENT_WORKDIR}/address-summary.md"', - 'echo done', - 'exit 0', - ].join('\n'), - idleMs, - }); - expect(r.status).toBe(0); - expect(r.failure).toBe(''); - } + it('keeps idle timeout validation finite and positive', () => { + expect(readFileSync(autofixRunnerScriptPath, 'utf8')).toContain(` +const QWEN_IDLE_TIMEOUT_MS = + Number.isFinite(parsedIdleTimeoutMs) && parsedIdleTimeoutMs > 0 + ? parsedIdleTimeoutMs + : 20 * 60 * 1000; +`); }); });