test(scripts): cover preparation cancellation and an abandoned client
Both are failure paths the registry owns: a signal that stops packing must report as a cancellation rather than as a pack failure, and a client that disconnects mid-tarball must not stop later requests. execa reports the first either as a resolved canceled result or as a thrown error, so the packing step now names both.
This commit is contained in:
1 parent
73d853a9ae
commit
eac927e143
2 files changed
+49
-4
No files matched your search
@@ -1,6 +1,7 @@
|
||||
/** Behavior of the local Official-bundle registry: closure selection, archive validation, HTTP, and shutdown. */
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { connect } from 'node:net'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
readReleaseMembers,
|
||||
readWorkspacePackages,
|
||||
selectBundleDependencies,
|
||||
packBundleArtifacts,
|
||||
startBundleRegistry,
|
||||
writeRegistryManifest,
|
||||
type BundleArtifact,
|
||||
@@ -254,6 +256,16 @@ describe('packed archives', () => {
|
||||
.toThrow(/@scope\/bundle: packed archive lacks its declared bundle patch cordis\.patch\.yml/u)
|
||||
})
|
||||
|
||||
it('reports a preparation stopped by a signal as a cancellation', async () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-cancel-')
|
||||
const { member, entry } = packFixture({ destination, name: '@scope/pkg', version: '1.0.0', source: 'export default 1\n' })
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(packBundleArtifacts({
|
||||
root, destination, members: [member], release: new Map([[member.name, entry]]), signal: controller.signal,
|
||||
})).rejects.toThrow(/packing was cancelled/u)
|
||||
})
|
||||
|
||||
it('rejects a missing archive and a package that is not a public release member', () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-missing-')
|
||||
const missing = workspacePackage('@scope/pkg', { version: '1.0.0' })
|
||||
@@ -356,6 +368,24 @@ describe('registry HTTP', () => {
|
||||
expect(external.headers.get('location')).toBe('https://registry.npmjs.org/@anthropic-ai%2fclaude-agent-sdk')
|
||||
})
|
||||
|
||||
it('keeps serving after a client abandons a tarball request', async () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-disconnect-')
|
||||
const { artifact } = fabricate({ destination, name: '@scope/local', version: '1.0.0', source: 'export default 1\n' })
|
||||
const url = await servingRun({ destination, artifacts: [artifact], workspaceNames: ['@scope/local'] })
|
||||
const { port, pathname } = new URL(url)
|
||||
const socket = connect(Number(port), '127.0.0.1')
|
||||
await new Promise<void>((resolveConnect, rejectConnect) => {
|
||||
socket.once('connect', resolveConnect)
|
||||
socket.once('error', rejectConnect)
|
||||
})
|
||||
socket.write(`GET ${pathname}tarballs/${artifact.file} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\n\r\n`)
|
||||
await new Promise<void>((resolveData) => { socket.once('data', () => { resolveData() }) })
|
||||
socket.destroy()
|
||||
const response = await fetch(`${url}@scope%2flocal`)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ name: '@scope/local' })
|
||||
})
|
||||
|
||||
it('rejects out-of-namespace, malformed, and non-GET requests, then stops listening', async () => {
|
||||
const destination = temporaryDirectory('dsh-local-registry-reject-')
|
||||
const { artifact } = fabricate({ destination, name: '@scope/local', version: '1.0.0', source: 'export default 1\n' })
|
||||
|
||||
@@ -367,10 +367,16 @@ export async function packBundleArtifacts(options: {
|
||||
'pack', '--recursive', `--workspace-concurrency=${String(Math.min(4, members.length))}`,
|
||||
...members.map(member => `--filter=${member.name}`), '--pack-destination', destination,
|
||||
])
|
||||
const result = await execa(invocation.command, invocation.args, {
|
||||
cwd: root, reject: false, killDescendants: true,
|
||||
...options.signal === undefined ? {} : { cancelSignal: options.signal },
|
||||
})
|
||||
let result
|
||||
try {
|
||||
result = await execa(invocation.command, invocation.args, {
|
||||
cwd: root, reject: false, killDescendants: true,
|
||||
...options.signal === undefined ? {} : { cancelSignal: options.signal },
|
||||
})
|
||||
} catch (error) {
|
||||
if (isCanceled(error)) throw new Error('packing was cancelled')
|
||||
throw error
|
||||
}
|
||||
if (result.isCanceled) throw new Error('packing was cancelled')
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`pnpm pack exited with ${String(result.exitCode ?? result.signal)}:\n${result.stdout}\n${result.stderr}`)
|
||||
@@ -378,6 +384,15 @@ export async function packBundleArtifacts(options: {
|
||||
return readPackedArtifacts({ destination, members, release })
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a settled or thrown child-process outcome was a cancellation.
|
||||
* @param value - the settled execa result or the error it rejected with.
|
||||
* @returns True when an abort signal ended the child.
|
||||
*/
|
||||
function isCanceled(value: unknown): boolean {
|
||||
return typeof value === 'object' && value !== null && 'isCanceled' in value && value.isCanceled === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate already packed archives and describe them as servable artifacts.
|
||||
* @param options - run directory, the members that were packed into it, and the public release index.
|
||||
|
||||
Reference in new issue
Block a user