Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions tests/contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
// clients are held to the same lines.

import assert from 'node:assert/strict'
import { once } from 'node:events'
import { createServer } from 'node:http'
import { connect } from 'node:net'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
Expand Down Expand Up @@ -173,8 +175,23 @@ export function assertContractHonoured(endpoint, request) {

// ------------------------------------------------------------------ harness --

const liveStubs = new Set()

/**
* Resolves once every live stub has read each connection made to it so far.
*
* A hook exiting does not mean its report was read. When the stub's process is
* starved past curl's --max-time, curl gives up with the request already in the
* socket, and the child's exit can reach the event loop before that connection
* is even accepted. Connecting once more and waiting to accept it drains the
* listen queue, which is FIFO; each accepted socket closing means its request
* was read. Call it after a hook exits and before asserting on `requests`.
*/
export const settleStubs = () => Promise.all([...liveStubs].map((stub) => stub.settle()))

export async function startStub({ status = 204 } = {}) {
const requests = []
const sockets = new Set()
const server = createServer((req, res) => {
let raw = ''
req.setEncoding('utf8')
Expand All @@ -186,12 +203,33 @@ export async function startStub({ status = 204 } = {}) {
res.writeHead(status).end()
})
})
server.on('connection', (socket) => {
sockets.add(socket)
socket.on('close', () => sockets.delete(socket))
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return {
const port = server.address().port

const settle = async () => {
const probe = connect(port, '127.0.0.1')
await once(probe, 'connect')
const accepted = () => [...sockets].some((s) => s.remotePort === probe.localPort)
while (!accepted()) await once(server, 'connection')
probe.destroy()
await Promise.all([...sockets].map((s) => once(s, 'close')))
}

const stub = {
requests,
port: server.address().port,
close: () => new Promise((resolve) => server.close(resolve)),
port,
settle,
close: () => {
liveStubs.delete(stub)
return new Promise((resolve) => server.close(resolve))
},
}
liveStubs.add(stub)
return stub
}

export async function withStub(fn, options) {
Expand Down
6 changes: 5 additions & 1 deletion tests/hooks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
assertContractHonoured,
lichEnv,
rejected,
settleStubs,
startStub,
withStub,
} from './contract.mjs'
Expand Down Expand Up @@ -75,7 +76,9 @@ function runHook(command, { env = {}, stdin = '', pluginRoot = true, tmpdir = nu
})
child.stdin.on('error', () => {}) // a script that reads no stdin is fine
child.stdin.end(stdin)
child.on('close', (code) => resolve({ code, stdout, stderr }))
child.on('close', (code) =>
settleStubs().then(() => resolve({ code, stdout, stderr })),
)
})
}

Expand Down Expand Up @@ -654,6 +657,7 @@ for (const state of ['busy', 'done', 'idle']) {
child.stdin.on('error', () => {})
const code = await new Promise((resolve) => child.on('close', resolve))
child.stdin.destroy()
await settleStubs()
assert.equal(code, 0)
assert.equal(stub.requests.length, 1)
assert.equal(assertContractHonoured('/hook', stub.requests[0]).body.state, state)
Expand Down
Loading