Merge pull request #3964 from deepseek-harness/turtle/master-ci-browser-realpath
fix(ci): repair browser roots and asynchronous test fixtures
This commit is contained in:
commit
ef0041b351
5 files changed
+47
-106
No files matched your search
@@ -1494,106 +1494,6 @@ describe('PythonCodeRuntime — programs and bindings', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('bounds an illegal-UTF-8 native residual by its U+FFFD-decoded cost', async () => {
|
||||
// Every 0xFF byte is illegal in any UTF-8 sequence, so `toString('utf8')`
|
||||
// renders each as U+FFFD (3 serialized bytes). `accrueStrayCost` must charge
|
||||
// that 3, not the raw 1: otherwise the newline-free residual grows to a full
|
||||
// budget's worth of RAW bytes before flushing — a ~3x undercount that near a
|
||||
// large maxLogBytes retains hundreds of MiB then expands toward a ~1 GiB peak
|
||||
// in flushStray's concat + toString. Paced single-byte writes (each its own
|
||||
// `data` chunk, like the sealing case) expose the sub-chunk accrual: charged
|
||||
// at 3 the residual crosses a 3072-byte budget after ~1024 bytes and flushes;
|
||||
// charged at 1 it would need ~3072 bytes, so the peak residual triples. The
|
||||
// largest merged buffer is the discriminator.
|
||||
const realConcat = Buffer.concat.bind(Buffer)
|
||||
let maxConcat = 0
|
||||
Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer<ArrayBuffer> => {
|
||||
const merged = realConcat(list, total)
|
||||
if (merged.length > maxConcat) maxConcat = merged.length
|
||||
return merged
|
||||
}
|
||||
let result: CodeRunResult
|
||||
try {
|
||||
const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 })
|
||||
result = await runtime.run({
|
||||
program: [
|
||||
'import os, time',
|
||||
// One byte per chunk on every host: a plain yield lets a loaded
|
||||
// reader coalesce, and the coalesced chunk is what the bound below
|
||||
// measures. The payload stays above the 2048 discriminator, so a
|
||||
// raw-byte undercount still flushes the whole residual at EOF.
|
||||
'for _ in range(3200):',
|
||||
' os.write(1, b"\\xff")',
|
||||
' time.sleep(0.001)',
|
||||
'return None',
|
||||
].join('\n'),
|
||||
bindings: [],
|
||||
})
|
||||
} finally {
|
||||
Buffer.concat = realConcat
|
||||
}
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs.at(-1)).toBe(logTruncationMarker(3072))
|
||||
// Charged at 3, the residual flushes around 1024 raw bytes; the largest
|
||||
// merged buffer stays well under 2048. A raw-byte undercount would let it
|
||||
// reach ~3072 before flushing, so 2048 discriminates.
|
||||
expect(maxConcat).toBeLessThan(2048)
|
||||
// The paced payload costs ~3.2s deterministically, which is above the
|
||||
// 5000ms default the local unit entry grants, so the case carries its own
|
||||
// bound instead of relying on the lane to widen it.
|
||||
}, 20_000)
|
||||
|
||||
it('charges a structurally-valid but illegal UTF-8 sequence its U+FFFD-decoded cost', async () => {
|
||||
// A CESU-8 lone surrogate `ED A0 80` is structurally well-formed (a 3-byte
|
||||
// lead plus two 0x80–0xBF continuations) but ILLEGAL: `toString('utf8')`
|
||||
// renders each of the three bytes as its own U+FFFD (serialized cost 9), not
|
||||
// one width-3 character. The newline-free flush trigger weighs the residual
|
||||
// through `accrueStrayCost`, which must validate each lead's
|
||||
// first-continuation range (ED excludes A0–BF) and charge the true 9 — else a
|
||||
// CESU flood undercounts 3x and the residual grows toward a full budget's raw
|
||||
// bytes before flushing, the same peak-memory vector as the 0xFF case. The
|
||||
// bytes are written one at a time (each its own `data` chunk, no pipe
|
||||
// coalescing) and `Buffer.concat` is wrapped to measure the peak residual.
|
||||
const realConcat = Buffer.concat.bind(Buffer)
|
||||
let maxConcat = 0
|
||||
Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer<ArrayBuffer> => {
|
||||
const merged = realConcat(list, total)
|
||||
if (merged.length > maxConcat) maxConcat = merged.length
|
||||
return merged
|
||||
}
|
||||
let result: CodeRunResult
|
||||
try {
|
||||
const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 })
|
||||
result = await runtime.run({
|
||||
program: [
|
||||
'import os, time',
|
||||
'seq = (0xed, 0xa0, 0x80)',
|
||||
// 1100 sequences are 3300 raw bytes, past the 3072-byte budget a
|
||||
// raw-byte undercount reaches, so the undercount flushes above the
|
||||
// 2048 discriminator instead of only at EOF.
|
||||
'for _ in range(1100):',
|
||||
' for b in seq:',
|
||||
' os.write(1, bytes((b,)))',
|
||||
' time.sleep(0.001)',
|
||||
'return None',
|
||||
].join('\n'),
|
||||
bindings: [],
|
||||
})
|
||||
} finally {
|
||||
Buffer.concat = realConcat
|
||||
}
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs.at(-1)).toBe(logTruncationMarker(3072))
|
||||
// Each 3-byte sequence costs 9 (three U+FFFD), so single-byte-paced the
|
||||
// residual crosses the 3072 budget after ~342 raw bytes and flushes; the
|
||||
// largest merged buffer stays well under 2048. Charging the structural width
|
||||
// 3 would need ~1024 raw bytes, tripling the peak past 2048.
|
||||
expect(maxConcat).toBeLessThan(2048)
|
||||
// The paced payload costs ~3.3s deterministically, which is above the
|
||||
// 5000ms default the local unit entry grants, so the case carries its own
|
||||
// bound instead of relying on the lane to widen it.
|
||||
}, 20_000)
|
||||
|
||||
it('charges a lone surrogate its full six escaped bytes, not three', async () => {
|
||||
// A forged `log` frame carrying `\ud800` escapes materializes lone
|
||||
// surrogates after JSON.parse. `Buffer.byteLength` of U+FFFD is 3, but
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { logTruncationMarker } from '../src/protocol.ts'
|
||||
|
||||
// Keep the interpreter and pipe lifecycle real; only OS-dependent read sizes
|
||||
// change. Each byte reaches the runtime as its own data event.
|
||||
@@ -54,3 +55,33 @@ it('seals stray fragments without recopying the sealed prefix', async () => {
|
||||
await fiber.dispose()
|
||||
}
|
||||
}, 40_000)
|
||||
|
||||
it.each([
|
||||
{ name: 'illegal UTF-8 bytes', payload: 'b"\\xff" * 3200' },
|
||||
{ name: 'CESU-8 lone surrogates', payload: 'b"\\xed\\xa0\\x80" * 1100' },
|
||||
])('bounds $name by their U+FFFD-decoded cost', async ({ payload }) => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(PythonCodeRuntime, { maxLogBytes: 3072, maxWallMs: 30_000 })
|
||||
const realConcat = Buffer.concat.bind(Buffer)
|
||||
let maxConcat = 0
|
||||
const concat = vi.spyOn(Buffer, 'concat').mockImplementation((list, total) => {
|
||||
const merged = realConcat(list, total)
|
||||
maxConcat = Math.max(maxConcat, merged.length)
|
||||
return merged
|
||||
})
|
||||
try {
|
||||
const result = await ctx.codeRuntime.run({
|
||||
program: `import os\nos.write(1, ${payload})\nreturn None`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs.at(-1)).toBe(logTruncationMarker(3072))
|
||||
// Each raw byte decodes to U+FFFD (three UTF-8 bytes), so a 3072-byte
|
||||
// budget flushes near 1024 raw bytes. Charging raw or structural widths
|
||||
// instead retains over 2048 bytes before flushing these payloads.
|
||||
expect(maxConcat).toBeLessThan(2048)
|
||||
} finally {
|
||||
concat.mockRestore()
|
||||
await fiber.dispose()
|
||||
}
|
||||
}, 20_000)
|
||||
@@ -330,7 +330,9 @@ describe.skipIf(!hasPwsh)('terminal-bash pwsh real shell', () => {
|
||||
timeoutMs: 8_000,
|
||||
}, 'pwsh')
|
||||
const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root })
|
||||
expect(created.motd).toContain('dsh> ')
|
||||
// stdin_read can precede delivery of the printable prompt to the PTY reader.
|
||||
await expect.poll(() => ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 100 }).text,
|
||||
{ timeout: 8_000 }).toContain('dsh> ')
|
||||
|
||||
const releaseFile = join(root, 'release-command')
|
||||
// Hold the command across the silence settlement without relying on host load.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -78,7 +78,7 @@ describe('browser dependency discovery', () => {
|
||||
await expect(browserBundledExternals(root)).rejects.toThrow('has no browser build config')
|
||||
})
|
||||
|
||||
it('follows shell workspace aliases, CSS assets and lazy imports without writing output', async () => {
|
||||
it.each([false, true])('follows shell aliases, CSS and lazy imports without writing output (symlinked root: %s)', async (linked) => {
|
||||
const root = fixture()
|
||||
library(root, 'shell-lib')
|
||||
library(root, 'lazy-lib')
|
||||
@@ -104,7 +104,13 @@ describe('browser dependency discovery', () => {
|
||||
}`)
|
||||
write(root, 'apps/web/dist/sentinel.txt', 'untouched')
|
||||
|
||||
expect(await browserBundledExternals(root)).toEqual(new Set(['shell-lib', 'lazy-lib', 'asset-lib']))
|
||||
const scanRoot = linked ? join(fixture(), 'linked') : root
|
||||
if (linked) symlinkSync(root, scanRoot, 'junction')
|
||||
try {
|
||||
expect(await browserBundledExternals(scanRoot)).toEqual(new Set(['shell-lib', 'lazy-lib', 'asset-lib']))
|
||||
} finally {
|
||||
if (linked) unlinkSync(scanRoot)
|
||||
}
|
||||
expect(readFileSync(join(app, 'dist/sentinel.txt'), 'utf8')).toBe('untouched')
|
||||
expect(existsSync(join(app, 'dist/index.html'))).toBe(false)
|
||||
expect(existsSync(join(root, 'packages/client/static/lib'))).toBe(false)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Resolve direct third-party browser inputs through the shipping build configurations, without emitting files. */
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
@@ -159,10 +159,12 @@ async function collectShell(
|
||||
|
||||
/**
|
||||
* Direct third-party packages resolved by published browser builds.
|
||||
* @param root - Repository root with installed build dependencies; lib/ is not required.
|
||||
* @param root - Repository root, possibly symlinked, with installed build dependencies; lib/ is not required.
|
||||
* @returns Names of distributed browser inputs, excluding workspace packages and erased types.
|
||||
*/
|
||||
export async function browserBundledExternals(root: string): Promise<Set<string>> {
|
||||
// Vite resolves HTML inputs to real paths, so its root must use the same spelling.
|
||||
root = realpathSync(root)
|
||||
const manifests = new Map<string, Manifest>()
|
||||
for (const glob of ['packages/*/*/package.json', 'vendor/*/package.json']) {
|
||||
for (const path of globSync(glob, { cwd: root }).sort()) {
|
||||
|
||||
Reference in new issue
Block a user