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
172 changes: 172 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
name: CI

# The gate on pull requests. Until this existed, chuzz ran nothing on a PR:
# `release.yml` fires on push to master and is the only other workflow, so the
# first build of any change was the macOS release job, after the merge.
#
# What this can and cannot check, and why it is split that way:
#
# `chuzz-control` is platform-neutral (serde, tokio, a unix socket) and builds
# and tests on Linux. `chuzz-gui` pulls the Blitz engine, which is a macOS
# preview runtime; nothing in either manifest is target-gated, so a Linux runner
# would try to build the whole windowing stack and fail for reasons no PR
# introduced. Making it build on Linux is separate portability work. So the
# Linux jobs cover what genuinely is portable, and the macOS job covers the app.
#
# Runners are `ubicloud-standard-*` throughout, except the macOS one: Ubicloud
# offers Linux only, so the app build uses the same Namespace profile
# `release.yml` does.

on:
pull_request:
workflow_dispatch:

# A force-push supersedes the run it interrupted. Unlike the release job, which
# must never be cancelled halfway, a cancelled PR check costs nothing.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
# Formatting needs no dependency graph, so it answers in seconds on the
# smallest runner and fails fast before anything expensive starts.
fmt:
runs-on: ubicloud-standard-2
steps:
- uses: actions/checkout@v4

- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt

- name: Check formatting
run: cargo fmt --all -- --check

# Reads Cargo.lock only, so it needs neither a toolchain nor a network. This
# is the check that would have caught the engine cascade being repinned
# halfway; see the script's own header for why that failure reads as a broken
# engine rather than as two of them.
one-rev:
runs-on: ubicloud-standard-2
steps:
- uses: actions/checkout@v4

- name: One rev per git source
run: scripts/check-one-rev-per-git-source.sh

# The portable half of the workspace, linted and tested for real.
control:
runs-on: ubicloud-standard-4
steps:
- uses: actions/checkout@v4

- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy

- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
shared-key: chuzz-control-ci

# `--all-targets` so the tests and the `chuzz-inspect` binary are linted
# too, not just the library. Warnings are denied here rather than in a
# config file so a local `cargo clippy` stays quiet and advisory.
- name: Clippy
run: cargo clippy -p chuzz-control --all-targets --all-features -- -D warnings

- name: Test
run: cargo test -p chuzz-control --all-features

# The frontend, which is where most changes actually land. Independent of the
# Rust jobs, so a TypeScript error does not wait on a cargo build.
#
# `bun install` must precede any build: the `prebuild` script runs
# `solid-layouts-library` from `node_modules/.bin`, and that binary arrives
# only because `solid-layouts-oxc` is a direct devDependency. Bun links the
# bins of direct dependencies into the root `.bin` and leaves a transitive one
# under its own dependent, which is how the release job failed for fourteen
# releases while `bun install` reported success. `--frozen-lockfile` so a PR
# cannot quietly resolve a different tree from the committed bun.lock.
frontend:
runs-on: ubicloud-standard-4
defaults:
run:
working-directory: apps/chuzz/frontend
steps:
- uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

# Proves the binary the release job needs is actually on PATH, and names
# it plainly if it is not. Cheap, and it turns the one failure that has
# historically reached master into a PR-time error with an obvious cause.
- name: Confirm the layouts CLI is linked
run: |
if [ ! -x node_modules/.bin/solid-layouts-library ]; then
echo "::error::solid-layouts-library is missing from node_modules/.bin."
echo "solid-layouts-oxc must stay a *direct* devDependency: bun links"
echo "the bins of direct dependencies only. See release.yml."
ls -la node_modules/.bin || true
exit 1
fi
echo "solid-layouts-library is linked"

- name: Lint
run: bun run lint

- name: Typecheck
run: bun run typecheck

- name: Test
run: bun run test:run

# The app itself, on the platform it targets. This is the job that proves a
# change compiles against the pinned engine, which no Linux runner can do.
#
# `build-app.sh` is deliberately not used: bundling, signing and stamping are
# the release job's business. A PR only needs to know the code compiles and
# its tests pass.
app:
runs-on: namespace-profile-agency-tahoe
steps:
- uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

# The Rust build script shells out to `bun run build`, so the frontend
# dependencies have to be present before cargo starts.
- name: Install frontend dependencies
working-directory: apps/chuzz/frontend
run: bun install --frozen-lockfile

- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin
components: clippy

- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
shared-key: chuzz-macos-ci

- name: Clippy
run: cargo clippy -p chuzz-gui --all-targets -- -D warnings

- name: Test
run: cargo test --workspace
4 changes: 2 additions & 2 deletions Cargo.lock

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

8 changes: 8 additions & 0 deletions apps/chuzz/frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ export interface BrowserEvents {
"status-changed": StatusReadout;
"panel-changed": PanelState;
"debug-entry": DebugEntry;
/**
* The macOS View > View Source item was chosen.
*
* Carries no payload on purpose. What view source means depends on the active
* tab, which the chrome already knows and already decides for Cmd-U; sending
* a URL from the menu handler would be a second answer to the same question.
*/
"menu-view-source": null;
}

export type BrowserEvent = keyof BrowserEvents;
4 changes: 4 additions & 0 deletions apps/chuzz/frontend/src/api/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export function createMockApi(): BrowserApi {
"status-changed": new Set(),
"debug-entry": new Set(),
"panel-changed": new Set(),
// Never emitted by the mock: there is no menu bar in a browser dev build.
// Present because the shell subscribes to it unconditionally, and an
// absent Set would throw on registration rather than simply stay quiet.
"menu-view-source": new Set(),
};

function emit<K extends keyof BrowserEvents>(event: K, payload: BrowserEvents[K]): void {
Expand Down
6 changes: 6 additions & 0 deletions apps/chuzz/frontend/src/stores/browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ function createBrowserStore() {
};
window.addEventListener("keydown", onKeyDown, true);

// The macOS menu item runs the keystroke's action rather than its own, so
// the two cannot drift. Tracked like every other listener: registration is
// async, and an unmount mid-flight would otherwise leave it holding this
// owner.
track(api.on("menu-view-source", () => runShortcut("view-source")));

onCleanup(() => {
disposed = true;
window.removeEventListener("keydown", onKeyDown, true);
Expand Down
2 changes: 1 addition & 1 deletion apps/chuzz/src/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,7 @@ async fn fetch_page_module(
/// pretty-print or re-serialise it. A document that showed a parsed and
/// re-emitted tree would be answering a different question, and for a page
/// whose claim is "there is no script here" it would be the wrong answer.
fn source_html(text: &str) -> String {
pub(crate) fn source_html(text: &str) -> String {
let escaped = text
.replace('&', "&amp;")
.replace('<', "&lt;")
Expand Down
75 changes: 75 additions & 0 deletions apps/chuzz/src/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,4 +424,79 @@ mod tests {
"the rows do not stack in order: {rows:?}"
);
}

/// `view-source:` renders through the capture path, escaped rather than
/// parsed.
///
/// Hermetic on purpose. The loader's `view-source:` branch fetches the
/// inner URL, so driving it end to end would put a network round trip in
/// the suite and make this test fail for reasons that have nothing to do
/// with rendering. What the branch does *after* the fetch is wrap the bytes
/// with `browser::source_html` and parse that, and it is the wrapping that
/// can regress: escaping that stopped escaping would render the source as a
/// page, which is the one thing view source must never do.
///
/// So this paints exactly what the loader would hand to the renderer, and
/// reads the pixels back the way the wasm test does.
#[test]
fn a_source_document_paints_its_markup_rather_than_rendering_it() {
use blitz_dom::{BaseDocument, DocumentConfig};

// A page whose rendered form is unmistakably different from its source:
// an <h1> would paint large and bold, and the tags would vanish.
const PAGE: &str = "<h1>Example Domain</h1><p>a &amp; b</p>";
let html = crate::browser::source_html(PAGE);

// The escaping is the contract the picture depends on, so assert it
// before painting: a failure here explains a failure below.
assert!(
html.contains("&lt;h1&gt;") && !html.contains("<h1>"),
"the markup must be escaped, not embedded: {html}"
);
assert!(
html.contains("&amp;amp;"),
"an entity in the source must itself be escaped, or the picture \
shows `&` where the server sent `&amp;`: {html}"
);

let document = blitz_html::HtmlDocument::from_html(
&html,
DocumentConfig {
html_parser_provider: Some(std::sync::Arc::new(blitz_html::HtmlProvider)),
..Default::default()
},
);
let mut document: BaseDocument = document.into_inner();

let png = scratch("view-source.png");
let _ = std::fs::remove_file(&png);
let tree = scratch("view-source-tree.txt");
let _ = std::fs::remove_file(&tree);
let buffer = super::paint(&mut document, 1.0, 1440, 960, Some(&tree));
super::write_png(&buffer, 1440, 960, &png).expect("the png should be written");

// The same reasoning as the wasm test: a blank image would satisfy any
// weaker assertion, and blank is this path's characteristic failure.
let painted = non_background_pixels(&png);
assert!(
painted.differing > 250,
"the source document painted almost nothing ({} of {}), which is \
what a missing font looks like",
painted.differing,
painted.total
);

// And the tree proves it is *source*, not a rendered page: the text
// sits in a <pre>, and no <h1> was ever created from the escaped input.
let dump = std::fs::read_to_string(&tree).expect("the tree dump should be written");
assert!(
boxes(&dump).iter().any(|box_| box_.name == "pre"),
"the source should be laid out in a <pre>: {dump}"
);
assert!(
!boxes(&dump).iter().any(|box_| box_.name == "h1"),
"an <h1> in the tree means the markup was rendered rather than \
shown: {dump}"
);
}
}
34 changes: 34 additions & 0 deletions apps/chuzz/src/document_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,40 @@ pub async fn load_for_capture(
) -> Result<CapturedDocument, Box<dyn std::error::Error>> {
use blitz_dom::Document as _;

// `view-source:` is the browser's, and a capture that could not take it was
// the one address a tab could show and a PNG could not. The scheme is not a
// fetchable one, so this has to come before the net provider sees it: the
// inner URL is what gets fetched, and the bytes are escaped rather than
// parsed.
//
// `browser::source_html` rather than a second copy of the escaping. What
// the capture writes has to be byte-for-byte what the tab shows, or the PNG
// stops being evidence about the browser and becomes evidence about this
// function.
//
// Nothing below applies to a source document: it has no scripts to run and
// no images to wait for, so it returns here rather than falling through to
// the script pump.
if request.url.scheme() == "view-source" {
let inner = request.url.path().to_owned();
let url = Url::parse(&inner).map_err(|error| format!("{inner} is not a URL: {error}"))?;
let (_, bytes) = net_provider
.fetch_async(Request::get(url))
.await
.map_err(|error| format!("could not fetch {inner}: {error:?}"))?;
let html = crate::browser::source_html(&decode_body(&bytes));
return Ok(CapturedDocument::Html(Box::new(
blitz_html::HtmlDocument::from_html(
&html,
DocumentConfig {
html_parser_provider: Some(Arc::new(HtmlProvider)),
..Default::default()
},
)
.into_inner(),
)));
}

let (resolved_url, bytes) = net_provider
.fetch_async(request)
.await
Expand Down
Loading
Loading