Skip to content
Open
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"dev": "node build.mjs --development",
"analyze": "node build.mjs --analyze",
"lint": "eslint --ext .js,.mjs,.jsx .",
"smoke": "node scripts/xvfb-smoke.mjs",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve caller cwd in the npm smoke entry

When this entry is invoked from another directory, such as cd /tmp && npm --prefix /workspace/chatGPTBox run smoke -- ..., npm runs the script with PWD=/workspace/chatGPTBox and exposes /tmp only through INIT_CWD. Consequently, the runner's relative --*-path and --artifacts-dir values resolve against the repository rather than the caller's directory, causing valid executables to be reported missing or artifacts to be written in the wrong place despite the documented caller-relative behavior. Pass or use INIT_CWD as the base directory for the npm entry.

Useful? React with 👍 / 👎.

@PeterDaveHello PeterDaveHello Sep 13, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The caller-relative wording is too broad for npm invocations. This entry follows npm's package-root working directory. The README now distinguishes direct Node/shell invocation from npm and recommends absolute paths across directories. The runner retains standard npm semantics; it does not reinterpret relative arguments through INIT_CWD.

"test": "node --import ./tests/setup/browser-shim.mjs --test",
"test:coverage": "c8 --all --reporter=text --reporter=lcov --reporter=json-summary --include=\"src/**/*.{mjs,jsx,js}\" node --import ./tests/setup/browser-shim.mjs --test",
"lint:fix": "eslint --ext .js,.mjs,.jsx . --fix",
Expand Down
4 changes: 4 additions & 0 deletions scripts/run-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/sh
# Compatibility entry point; Node owns resources and the exit status.
script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) || exit 2
exec node "$script_dir/xvfb-smoke.mjs" "$@"
162 changes: 162 additions & 0 deletions scripts/smoke/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Browser smoke tests

This maintained Linux smoke tool loads the built extension into real, isolated
headless Chromium and Firefox sessions. It does not use Xvfb, real API credentials,
external test websites, or the user's browser profile. It does not install software,
download browsers, build the extension, or integrate with CI automatically.

## Prerequisites and usage

Install Node 22+, project dependencies (`npm ci`), full Chromium or Chrome for
Testing, Firefox, and a native Linux geckodriver binary. Browser installation is an
explicit prerequisite, not part of the runner. An npm geckodriver launcher is
rejected because it may download a driver. Use the actual extracted executable.

Build in the same worktree first:

```sh
npm run build
npm run smoke -- --browser all \
--chromium-path /path/to/chromium \
--firefox-path /path/to/firefox \
--geckodriver-path /path/to/geckodriver
```

Firefox installs a temporary add-on from a per-run copy of `build/firefox`, not
`build/firefox.zip`. The copy must match the directory hash recorded during
preflight; a changed or incomplete build fails before Firefox starts. Popup paths
and expected identity come from that verified copy's manifest, including its query
string. This validates the unpacked build, not the distribution ZIP. A missing or
stale distribution ZIP does not select different JavaScript for the smoke run.

Use `--browser chromium` or `--browser firefox` for a single browser. The default
is `all`, with Chromium followed by Firefox. All selected prerequisites are checked
before any browser starts; missing browsers are failures, not skipped passes.
Paths supplied on the command line override PATH discovery. Discovery searches
explicit PATH directories; empty components do not imply the current directory.
Use an explicit `--*-path ./executable` or PATH entry `.` to select a local binary.
Relative executable and artifact-parent paths use the runner's working directory.
Direct Node and shell invocations preserve the caller's directory. `npm run smoke`
runs from the package root, including when selected with `npm --prefix PATH`.
The runner does not use `INIT_CWD` for path resolution. Use absolute paths when
invoking the npm entry from another directory to avoid ambiguity.
Build entry paths are fixed at `build/<browser>` in the runner's own worktree.
Chromium accepts a stable build-root symlink, whose canonical target may be outside
the worktree; Firefox requires a non-symlink build root. Paths containing spaces work.

`node scripts/xvfb-smoke.mjs` and `sh scripts/run-smoke.sh` are compatibility
entry points to the same runner. Despite the historical filename, Xvfb is not
required. `--help` lists options. There is no automatic sandbox-disabling flag;
the browser must be able to start with its normal sandbox.

Use full Chromium or Chrome for Testing, not `chrome-headless-shell` or branded
Google Chrome. Chrome 137+ removed the command-line extension-loading flag from
branded builds. See the [Chromium announcement](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/1-g8EFx2BBY/m/S0ET5wPjCAAJ).
Firefox requires geckodriver's `--allow-system-access` for privileged popup
navigation, and dynamic port flags; see [geckodriver flags](https://firefox-source-docs.mozilla.org/testing/geckodriver/Flags.html).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## What is verified

- The installed extension's identity, version, and rendered popup.
- A popup extension port reaches the real background implementation, which sends
a Chat Completions request to the run's loopback-only mock server.
- A first answer arrives while the server deliberately holds the rest of the
response; after release, the complete Unicode answer, one completion message,
and one conversation record are asserted.
- An HTTP failure reports an error without a successful completion or new record.

This tests the committed Chat Completions route and does not require the separate
Responses API feature. Exact byte splitting, UTF-8 boundaries, and CRLF parsing
remain deterministic unit tests: separate server writes do not guarantee separate
browser network reads. These checks are a focused runtime smoke test, not a
replacement for all site-adapter and keyboard interaction testing in AGENTS.md.

## Isolation, results, and cleanup

Every invocation gets unique browser profiles, a unique artifact directory, and
OS-assigned loopback ports. Concurrent invocations do not share browser state.
Chromium's debugging endpoint is read from its own profile's `DevToolsActivePort`;
geckodriver's endpoint comes from its own startup log.

`--artifacts-dir PATH` selects a parent directory. The tool creates a unique
`chatgptbox-smoke-*` child, retaining `report.json`, browser logs, and available
failure screenshots/DOM. Reports include selected browsers, versions, build
hashes, checks, failure stage, cleanup errors, and exit status. Retained artifacts
must stay outside the selected build directories; parents inside a build,
including symlink aliases, are rejected before writing. Ancestor directories
such as the worktree root or `/tmp` are allowed because each run gets a new child.
Retained artifacts include Firefox's `extension/` snapshot and its `snapshotDir`
in the report.
For Firefox, `preflightManifestVersion` identifies the initial manifest, and
`manifestVersion` is set from the verified snapshot after successful startup.
Chromium's `manifestVersion` starts with the preflight value and is updated from
the rechecked build on successful startup. `artifactSha256` is the preflight
directory hash; Firefox verifies its snapshot against it before startup.
Chromium rechecks the live directory against that hash before launch and derives
startup identity from the on-disk manifest, not stale caller metadata. Directory
hashes include entry types, length-framed paths and file bytes, and empty directories.
If startup fails, the retained snapshot may be absent or incomplete.
Do not modify a build or the retained snapshot during a run.
Use trusted builds and keep their paths, the artifact parent, and all ancestors
stable during the run. Path-based validation and hashing are not an atomic security
boundary against concurrent directory or symlink substitution and cannot prevent
outside bytes entering retained artifacts if this precondition is violated.
Temporary browser profiles are removed after confirming that their managed process
groups have no live members. If group termination cannot be confirmed within the
cleanup budget, the run fails and preserves the profile at the path recorded in the
report. The tool never removes the artifact parent or user profiles. Browser logs
and failure DOM may contain test data; review artifacts before sharing.

Each managed command runs inside an isolated group with a small Node supervisor.
Shutdown first closes browser protocols, then asks the supervisor through a private
IPC channel to signal its own group with SIGTERM and finally SIGKILL (including
itself). The parent never sends termination signals to remembered numeric PIDs or
PGIDs, which can be reused by the OS. The target does not inherit the control channel.
Target exit reports are separate from supervisor exit: if a final group SIGKILL
prevents a target report, its exit status is explicitly unavailable, not inferred.

Browser and driver executables must keep profile-using descendants in these managed
groups. Do not use wrappers that daemonize or move those descendants to another
process group or session. This is process-group cleanup, not containment of every
descendant: a process that leaves its group (for example, using `setsid()`) is not
reliably detected or terminated. In that case, the run may report success and remove
its temporary profile while the escaped process remains alive. A PASS does not prove
that no descendants escaped their groups.

Cleanup is idempotent and bounded, and attempts remaining resources even when one
cleanup fails. SIGINT and SIGTERM trigger cleanup; a second signal asks supervisors
to accelerate termination. Control-channel loss triggers local supervisor cleanup,
but unexpected loss of the supervisor remains an error and preserves its profile.
An unresponsive or forcibly killed supervisor, machine failure, or uninterruptible
processes can prevent cleanup. Detected loss of supervisor ownership or failure to
confirm group termination is reported as an error; the parent does not retry
termination using an unverified group ID. This does not detect the escaped processes
described above.

Each browser startup is limited to 60 seconds. Request timeouts default to 10 seconds
for Chromium CDP and 30 seconds for Firefox WebDriver; Firefox session creation uses
60 seconds, still subject to the startup limit. The selected-browser loop has a shared
five-minute limit; each lifecycle cleanup has a ten-second waiting budget. Readiness checks
may retry, but session creation, addon installation, and test requests never do.

| Exit status | Meaning |
| ----------- | --------------------------------------------------- |
| 0 | Every selected browser passed and cleanup succeeded |
| 1 | Test, runtime, reporting, or cleanup failure |
| 2 | Invalid arguments or missing prerequisites/builds |
| 130 | SIGINT |
| 143 | SIGTERM |

The shell and npm entry points preserve failure status. No fixed `/tmp/smoke.done`
or `/tmp/smoke.out` files are used. A Firefox-only pass is not an all-browser pass.
The console prints the artifact location, not a provisional PASS or exit code:
signals can still arrive while that output is pending. Use the process exit status
and `report.json` for the final result.

## Development checks

Run the targeted formatter on changed files, `npm run lint`, `shellcheck
scripts/run-smoke.sh`, `npm test`, `npm run build`, then the actual browser smoke.
`npm test` includes fake-protocol and real child-process lifecycle tests, but never
launches a real browser automatically. It needs loopback socket access for mock
servers. No new browser automation package or indirect `ws` dependency is used.
72 changes: 72 additions & 0 deletions scripts/smoke/artifacts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { createHash } from 'node:crypto'
import { Buffer } from 'node:buffer'
import { createReadStream, createWriteStream } from 'node:fs'
import { lstat, mkdir, readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { pipeline } from 'node:stream/promises'

export async function hashArtifact(directory, signal) {
const hash = createHash('sha256').update('chatgptbox-smoke-artifact-v2\0')
function frame(bytes) {
const length = Buffer.alloc(8)
length.writeBigUInt64BE(BigInt(bytes.length))
hash.update(length).update(bytes)
}
async function visit(relative = '') {
signal?.throwIfAborted()
const entries = await readdir(join(directory, relative), { withFileTypes: true })
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
for (const entry of entries) {
signal?.throwIfAborted()
const path = join(relative, entry.name)
if (entry.isDirectory()) {
hash.update('d')
frame(Buffer.from(path))
await visit(path)
} else if (entry.isFile()) {
hash.update('f')
frame(Buffer.from(path))
frame(await readFile(join(directory, path), { signal }))
} else throw new Error(`Unexpected non-regular build artifact: ${path}`)
}
}
await visit()
signal?.throwIfAborted()
return hash.digest('hex')
}

export async function snapshotArtifact(source, destination, expectedHash, signal) {
signal?.throwIfAborted()
if (typeof expectedHash !== 'string' || !/^[a-f0-9]{64}$/.test(expectedHash))
throw new Error('Missing preflight build hash')
if (!(await lstat(source)).isDirectory()) throw new Error('Build source must be a directory')
// The destination belongs to this run's retained artifacts, never a browser profile.
await mkdir(destination)
async function copyDirectory(from, to) {
signal?.throwIfAborted()
for (const entry of await readdir(from, { withFileTypes: true })) {
signal?.throwIfAborted()
const input = join(from, entry.name)
const output = join(to, entry.name)
if (entry.isDirectory()) {
await mkdir(output)
await copyDirectory(input, output)
} else if (entry.isFile()) {
try {
// pipeline destroys both streams on abort and waits for their closure.
await pipeline(createReadStream(input), createWriteStream(output, { flags: 'wx' }), {
signal,
})
} catch (error) {
signal?.throwIfAborted()
throw error
}
} else throw new Error(`Unexpected non-regular build artifact: ${input}`)
}
}
await copyDirectory(source, destination)
signal?.throwIfAborted()
const actualHash = await hashArtifact(destination, signal)
if (actualHash !== expectedHash) throw new Error('Build changed since preflight')
return actualHash
}
112 changes: 112 additions & 0 deletions scripts/smoke/cdp.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { bounded } from './lifecycle.mjs'

// Register the transport before awaiting the handshake or issuing any RPCs.
export async function connectCDP(
url,
{ lifecycle, signal, timeoutMs = 10000, WebSocketImpl = globalThis.WebSocket },
) {
signal?.throwIfAborted()
let socket
let nextId = 0
let failure
let closed = false
let closing
let closeFailure
const pending = new Map()
let resolveOpen, rejectOpen, resolveClosed
const opened = new Promise((resolve, reject) => {
resolveOpen = resolve
rejectOpen = reject
})
const finished = new Promise((resolve) => {
resolveClosed = resolve
})
const fail = (error) => {
failure ??= error
rejectOpen(failure)
for (const request of pending.values()) request.reject(failure)
pending.clear()
}
const onAbort = () => {
const error = signal.reason ?? new Error('CDP connection aborted')
rejectOpen(error)
for (const [id, request] of pending) {
if (request.signal !== signal) continue
request.reject(error)
pending.delete(id)
}
}
const cleanup = () => {
if (closing) return closing
if (!socket) return Promise.resolve()
closeFailure = failure
signal?.removeEventListener('abort', onAbort)
fail(new Error('CDP connection closed'))
closing = (async () => {
if (closed) return
socket.close()
Comment thread
Copilot marked this conversation as resolved.
await bounded(finished, 2000, 'Close CDP connection')
})()
return closing
}
const close = async () => {
await cleanup()
if (closeFailure) throw closeFailure
}
lifecycle.defer('Close Chromium CDP connection', cleanup)
socket = new WebSocketImpl(url)
socket.addEventListener('open', resolveOpen, { once: true })
socket.addEventListener('error', () => fail(new Error('CDP WebSocket transport error')))
socket.addEventListener('close', () => {
closed = true
signal?.removeEventListener('abort', onAbort)
fail(new Error('CDP WebSocket disconnected'))
resolveClosed()
})
socket.addEventListener('message', ({ data }) => {
let message
try {
message = JSON.parse(data)
if (!message || typeof message !== 'object' || Array.isArray(message)) {
throw new Error('Expected a CDP message object')
}
} catch (error) {
fail(new Error('Invalid CDP WebSocket message', { cause: error }))
return
}
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
if (message.error) {
const error = new Error(`${request.method}: ${message.error.message}`)
error.code = message.error.code
request.reject(error)
} else if (!Object.hasOwn(message, 'result')) {
request.reject(new Error(`${request.method}: missing CDP result`))
} else {
request.resolve(message.result)
}
})
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) onAbort()
await bounded(opened, timeoutMs, 'Connect Chromium CDP', signal)

return {
close,
async request(method, params = {}, sessionId, options = {}) {
const requestSignal = Object.hasOwn(options, 'signal') ? options.signal : signal
requestSignal?.throwIfAborted()
if (failure) throw failure
const id = ++nextId
const response = new Promise((resolve, reject) => {
pending.set(id, { resolve, reject, method, signal: requestSignal })
})
try {
socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }))
return await bounded(response, options.timeoutMs ?? timeoutMs, method, requestSignal)
} finally {
pending.delete(id)
}
},
}
}
Loading