-
Notifications
You must be signed in to change notification settings - Fork 876
Add isolated browser smoke runner with safe process cleanup #1072
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
PeterDaveHello
wants to merge
1
commit into
ChatGPTBox-dev:master
Choose a base branch
from
PeterDaveHello:fixSmokeRunnerSafety
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" "$@" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). | ||
|
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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
|
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) | ||
| } | ||
| }, | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this entry is invoked from another directory, such as
cd /tmp && npm --prefix /workspace/chatGPTBox run smoke -- ..., npm runs the script withPWD=/workspace/chatGPTBoxand exposes/tmponly throughINIT_CWD. Consequently, the runner's relative--*-pathand--artifacts-dirvalues 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 useINIT_CWDas the base directory for the npm entry.Useful? React with 👍 / 👎.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.