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
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ Firefox desktop is also a supported release target, but its support contract is
different:

- compile/run, Monaco, and extension-runtime flows are supported
- programs that read `std::cin` use pre-supplied buffered stdin (up to 256 KiB);
live prompt-by-prompt stdin remains available on Chromium-family builds
- file open/save and folder import use fallback browser flows rather than
Chromium File System Access APIs
- persistent folder write-back and directory-handle session restore may be
Expand All @@ -232,7 +234,7 @@ Full parity requires:
- File System Access APIs (`showOpenFilePicker`, `showDirectoryPicker`,
`showSaveFilePicker`)
- Web Workers and WebAssembly
- `SharedArrayBuffer` and `Atomics.waitAsync` for interactive stdin
- `SharedArrayBuffer` and `Atomics.waitAsync` for Chromium live interactive stdin
- Managed browser policies that allow local file read/write prompts

### Release-blocking checks
Expand Down Expand Up @@ -459,8 +461,8 @@ manual/GitHub-distributed channel.
4. For self-distribution, verify that the protected release workflow produced a
signed artifact under `release/firefox-unlisted/`.
5. Install the signed XPI in Firefox and complete the manual QA checklist
below, paying special attention to the documented workspace-persistence
limitations.
in `docs/firefox-stdin-runtime-acceptance.md`, paying special attention to
buffered stdin and the documented workspace-persistence limitations.

### Manual release QA checklist

Expand Down Expand Up @@ -524,7 +526,9 @@ Copy the resulting `clang.js` and `clang.wasm` into `dist/clang/`.
script" dialog. The compiler runs in a dedicated Web Worker to avoid blocking
the UI.
- **Browser scope**: Full parity targets desktop Chrome, Edge, Brave, and
Chromium. Firefox and Safari are outside the current release target.
Chromium. Firefox is a compatible release target with buffered stdin and
workspace-persistence limitations; Safari is outside the current release
target.
- **Managed browsers**: Enterprise policies that block File System Access prompts
prevent full local workspace read/write support.

Expand Down
88 changes: 88 additions & 0 deletions docs/firefox-stdin-runtime-acceptance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Firefox buffered stdin runtime acceptance

Use this procedure to prove GitHub issue #53 is fixed in a real Firefox
extension context. `npm run test:browser:firefox` validates packaging and
manifest compatibility, but it does not execute a compiled program in Firefox.

## Setup

1. Run:

```bash
npm run build
npm run test:browser:firefox
```

2. Open `about:debugging#/runtime/this-firefox` in Firefox.
3. Choose **Load Temporary Add-on** and select `dist-firefox/manifest.json`.
For release validation, repeat with the signed XPI under
`release/firefox-unlisted/`.
4. Open browser.cpp from the extension toolbar action.

Record the Firefox version and the unpacked directory or signed XPI path used.

## Test program

Replace the editor contents with exactly:

```cpp
#include <iostream>
#include <string>

int main() {
std::string name;
int age = 0;

std::cout << "Name? ";
std::cin >> name;
std::cout << "Age? ";
std::cin >> age;

if (!std::cin) {
std::cerr << "input failed\n";
return 2;
}

std::cout << "\nHello " << name << ", next year " << (age + 1) << "\n";
return 0;
}
```

Choose **Compile and Run**. In the **Pre-supplied stdin** dialog, enter exactly:

```text
Ada
41
```

Keep the final newline after `41`, then choose **Run program**.

## Expected result

The process exits with code `0`, stderr is empty, and stdout is exactly:

```text
Name? Age?␠
Hello Ada, next year 42
```

The `␠` marker represents the single trailing ASCII space emitted after
`Age?`.

The terminal and browser console must not contain any of these strings:

```text
Interactive stdin requires SharedArrayBuffer
Cross-Origin-Opener-Policy
Cross-Origin-Embedder-Policy
```

## PR evidence

Paste the following into the pull request:

- Firefox version
- tested artifact path
- observed stdout
- confirmation that stderr was empty
- confirmation that none of the old SharedArrayBuffer/COOP/COEP errors appeared
3 changes: 2 additions & 1 deletion docs/release-playbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ real Firefox desktop build:
2. Confirm Monaco renders and the default sample appears without blocking
console/runtime errors.
3. Compile and run the default sample program.
4. Run a program that reads stdin and confirm terminal interaction works.
4. Complete `docs/firefox-stdin-runtime-acceptance.md` and confirm pre-supplied
buffered stdin works without the old SharedArrayBuffer/COOP/COEP error.
5. Open a local source file with Firefox's fallback picker and save changes.
6. Import a folder, compile a multi-file project, and confirm diagnostics appear
in the expected file.
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "browser.cpp",
"short_name": "browser.cpp",
"description": "In-browser C++20 IDE powered by Monaco Editor and WASM Clang",
"version": "0.3.2",
"version": "0.4.0",
"minimum_chrome_version": "105",
"icons": {
"16": "icons/icon16.png",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "browser.cpp",
"version": "0.3.2",
"version": "0.4.0",
"description": "In-browser C++20 IDE with WASM Clang toolchain",
"private": true,
"scripts": {
Expand All @@ -10,7 +10,7 @@
"lint": "eslint .",
"build": "npm run build:webpack && npm run build:targets",
"build:firefox": "npm run build",
"test:e2e": "node --experimental-detect-module --test scripts/e2e-session-persistence.test.mjs scripts/e2e-session-restore-choice.test.mjs scripts/e2e-multifile-build.test.mjs scripts/e2e-workspace-file-tracking.test.mjs scripts/e2e-terminal-mkdir.test.mjs scripts/e2e-terminal-stop.test.mjs scripts/e2e-terminal-git-removal.test.mjs scripts/e2e-terminal-stop-icon.test.mjs scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs scripts/e2e-wasi-shim.test.mjs scripts/e2e-release-packaging.test.mjs",
"test:e2e": "node --experimental-detect-module --test scripts/e2e-session-persistence.test.mjs scripts/e2e-session-restore-choice.test.mjs scripts/e2e-multifile-build.test.mjs scripts/e2e-workspace-file-tracking.test.mjs scripts/e2e-terminal-mkdir.test.mjs scripts/e2e-terminal-stop.test.mjs scripts/e2e-terminal-git-removal.test.mjs scripts/e2e-terminal-stop-icon.test.mjs scripts/e2e-buffered-stdin-ui.test.mjs scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs scripts/e2e-wasi-shim.test.mjs scripts/e2e-run-request.test.mjs scripts/e2e-release-packaging.test.mjs",
"test:preflight-clang": "node scripts/preflight-clang-artifacts.js",
"test:browser:chrome": "npm run test:preflight-clang && node scripts/smoke-browser.mjs chrome",
"test:browser:edge": "npm run test:preflight-clang && node scripts/smoke-browser.mjs edge",
Expand Down
2 changes: 2 additions & 0 deletions scripts/build-target-manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ function createChromiumManifest(baseManifest) {
function createFirefoxManifest(baseManifest, firefoxOverlay) {
const manifest = mergeObjects(baseManifest, firefoxOverlay);
delete manifest.minimum_chrome_version;
delete manifest.cross_origin_opener_policy;
delete manifest.cross_origin_embedder_policy;
manifest.background = {
scripts: ['firefox-background.js'],
};
Expand Down
2 changes: 2 additions & 0 deletions scripts/e2e-browser-compatibility.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ test('e2e: reports full Chromium-family capability support', () => {
assert.deepEqual(report.limitations, []);
assert.equal(report.capabilities.directoryPicker, true);
assert.equal(report.capabilities.sharedArrayBuffer, true);
assert.equal(report.capabilities.interactiveStdin, true);
assert.equal(report.capabilities.stdinMode, 'interactive');
});

test('e2e: reports missing full-parity browser APIs', () => {
Expand Down
25 changes: 25 additions & 0 deletions scripts/e2e-buffered-stdin-ui.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';

test('e2e: buffered stdin dialog has accessible controls and status feedback', async () => {
const html = await readFile('src/ui/index.html', 'utf8');
const dialog = html.match(/<dialog id="buffered-stdin-dialog"[\s\S]*?<\/dialog>/)?.[0];

assert.ok(dialog, 'buffered stdin dialog markup should exist');
assert.match(dialog, /aria-labelledby="buffered-stdin-title"/);
assert.match(dialog, /<label for="buffered-stdin-input"/);
assert.match(dialog, /<textarea[\s\S]*id="buffered-stdin-input"[\s\S]*aria-describedby="buffered-stdin-feedback"/);
assert.match(dialog, /<textarea[\s\S]*maxlength="262144"/);
assert.match(dialog, /id="buffered-stdin-feedback"[\s\S]*aria-live="polite"/);
assert.match(dialog, /<button[^>]*id="buffered-stdin-cancel"/);
assert.match(dialog, /<button[^>]*id="buffered-stdin-run"/);
});

test('e2e: buffered stdin dialog styles remain usable in narrow extension windows', async () => {
const css = await readFile('src/ui/styles.css', 'utf8');

assert.match(css, /#buffered-stdin-dialog\s*\{[\s\S]*width:\s*min\(/);
assert.match(css, /#buffered-stdin-input\s*\{[\s\S]*resize:\s*vertical/);
assert.match(css, /buffered-stdin-feedback/);
});
26 changes: 23 additions & 3 deletions scripts/e2e-firefox-compatibility.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ function firefoxLikeRoot(overrides = {}) {
},
Worker() {},
WebAssembly: { instantiate() {} },
SharedArrayBuffer() {},
Atomics: { waitAsync() {} },
crossOriginIsolated: true,
SharedArrayBuffer: undefined,
Atomics: {},
crossOriginIsolated: false,
...overrides,
};
}
Expand All @@ -54,8 +54,12 @@ test('e2e: Firefox compatibility report accepts supported fallback-based capabil
assert.equal(report.ok, true);
assert.equal(report.capabilities.browserFamily, 'firefox');
assert.equal(report.capabilities.supportLevel, 'compatible');
assert.equal(report.capabilities.interactiveStdin, false);
assert.equal(report.capabilities.stdinMode, 'buffered');
assert.deepEqual(report.missing, []);
assert.ok(report.limitations.some((item) => item.key === 'limitedInteractiveStdin'));
assert.ok(report.limitations.some((item) => item.key === 'firefoxWorkspacePersistence'));
assert.ok(message.includes('pre-supplied buffered stdin'));
assert.ok(message.includes('Persistent folder write-back'));
});

Expand All @@ -70,6 +74,8 @@ test('e2e: target manifest generation creates a Firefox manifest without Chromiu
service_worker: 'service-worker.js',
type: 'module',
},
cross_origin_opener_policy: { value: 'same-origin' },
cross_origin_embedder_policy: { value: 'require-corp' },
});
writeJson(path.join(repoRoot, 'manifest.firefox.json'), {
browser_specific_settings: {
Expand All @@ -92,6 +98,16 @@ test('e2e: target manifest generation creates a Firefox manifest without Chromiu

assert.equal(firefoxManifest.minimum_chrome_version, undefined);
assert.equal(firefoxManifest.background.service_worker, undefined);
assert.equal(firefoxManifest.cross_origin_opener_policy, undefined);
assert.equal(firefoxManifest.cross_origin_embedder_policy, undefined);
assert.deepEqual(
result.chromiumManifest.cross_origin_opener_policy,
{ value: 'same-origin' }
);
assert.deepEqual(
result.chromiumManifest.cross_origin_embedder_policy,
{ value: 'require-corp' }
);
assert.deepEqual(firefoxManifest.background.scripts, ['firefox-background.js']);
assert.equal(firefoxManifest.browser_specific_settings.gecko.id, 'browser.cpp@example.test');
});
Expand All @@ -105,6 +121,8 @@ test('e2e: createFirefoxManifest strips Chromium-specific background configurati
type: 'module',
},
minimum_chrome_version: '105',
cross_origin_opener_policy: { value: 'same-origin' },
cross_origin_embedder_policy: { value: 'require-corp' },
},
{
browser_specific_settings: {
Expand All @@ -117,5 +135,7 @@ test('e2e: createFirefoxManifest strips Chromium-specific background configurati

assert.equal(manifest.minimum_chrome_version, undefined);
assert.equal(manifest.background.service_worker, undefined);
assert.equal(manifest.cross_origin_opener_policy, undefined);
assert.equal(manifest.cross_origin_embedder_policy, undefined);
assert.deepEqual(manifest.background.scripts, ['firefox-background.js']);
});
81 changes: 81 additions & 0 deletions scripts/e2e-run-request.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';

import {
BUFFERED_STDIN_MAX_BYTES,
validateRunRequest,
} from '../src/workers/run-request.mjs';

function interactiveRequest(overrides = {}) {
return {
type: 'run',
stdinMode: 'interactive',
sharedBuffer: new SharedArrayBuffer(16),
vfsFiles: [{ path: 'input.txt', bytes: new Uint8Array([1, 2]) }],
binaryBytes: new Uint8Array([0, 97, 115, 109]),
...overrides,
};
}

test('e2e: worker run contract accepts and normalizes all stdin modes', () => {
const interactive = validateRunRequest(interactiveRequest());
assert.equal(interactive.ok, true);
assert.equal(interactive.value.stdin.mode, 'interactive');

const buffered = validateRunRequest(interactiveRequest({
stdinMode: 'buffered',
sharedBuffer: undefined,
stdinBuffer: new TextEncoder().encode('Ada\n41\n').buffer,
}));
assert.equal(buffered.ok, true);
assert.equal(buffered.value.stdin.mode, 'buffered');
assert.deepEqual(
[...buffered.value.stdin.bytes],
[...new TextEncoder().encode('Ada\n41\n')]
);

const none = validateRunRequest(interactiveRequest({
stdinMode: 'none',
sharedBuffer: undefined,
}));
assert.equal(none.ok, true);
assert.deepEqual(none.value.stdin, { mode: 'none' });
});

test('e2e: worker run contract rejects mismatched stdin variants', () => {
const cases = [
interactiveRequest({ sharedBuffer: new ArrayBuffer(16) }),
interactiveRequest({ stdinMode: 'buffered', sharedBuffer: undefined, stdinBuffer: 'Ada' }),
interactiveRequest({ stdinMode: 'none', sharedBuffer: new SharedArrayBuffer(16) }),
interactiveRequest({ stdinMode: 'unknown' }),
];

for (const request of cases) {
const result = validateRunRequest(request);
assert.equal(result.ok, false);
assert.match(result.error, /stdin/i);
}
});

test('e2e: worker run contract rejects buffered stdin above the shared size limit', () => {
const result = validateRunRequest(interactiveRequest({
stdinMode: 'buffered',
sharedBuffer: undefined,
stdinBuffer: new Uint8Array(BUFFERED_STDIN_MAX_BYTES + 1),
}));

assert.equal(result.ok, false);
assert.match(result.error, /256 KiB/);
});

test('e2e: worker run contract rejects invalid binary and VFS byte fields', () => {
const invalidBinary = validateRunRequest(interactiveRequest({ binaryBytes: 'wasm' }));
assert.equal(invalidBinary.ok, false);
assert.match(invalidBinary.error, /binaryBytes/);

const invalidVfs = validateRunRequest(interactiveRequest({
vfsFiles: [{ path: 'input.txt', bytes: [1, 2] }],
}));
assert.equal(invalidVfs.ok, false);
assert.match(invalidVfs.error, /vfsFiles/);
});
Loading
Loading