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
38 changes: 26 additions & 12 deletions .github/workflows/release-plan-browser.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,9 @@ on:
- ".github/workflows/release-plan-browser.yml"
- "scripts/render-release-plan-html.mjs"
- "tests/release_plan_report_test.mjs"
- "tests/release_plan_accessibility_test.mjs"
- "tests/browser/test_release_plan_report.py"
- "tests/browser/test_release_plan_cross_browser.py"
- "tests/fixtures/release-report/**"
- "docs/release-plan-report.md"
- "src/release.rs"
Expand All@@ -20,7 +22,9 @@ on:
- ".github/workflows/release-plan-browser.yml"
- "scripts/render-release-plan-html.mjs"
- "tests/release_plan_report_test.mjs"
- "tests/release_plan_accessibility_test.mjs"
- "tests/browser/test_release_plan_report.py"
- "tests/browser/test_release_plan_cross_browser.py"
- "tests/fixtures/release-report/**"
- "docs/release-plan-report.md"
- "src/release.rs"
Expand All@@ -38,10 +42,15 @@ concurrency:
cancel-in-progress: true

jobs:
chromium:
name: Generate and review release plan in Chromium
browser:
name: Generate and review in ${{ matrix.browser }}
runs-on: ubuntu-latest
timeout-minutes: 35
timeout-minutes: 45
strategy:
fail-fast: false
max-parallel: 2
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
Expand All@@ -67,9 +76,12 @@ jobs:
with:
python-version: "3.13"

- name: Test the fail-closed renderer
- name: Test the fail-closed renderer and accessibility structure
working-directory: zed-cli
run: node --test tests/release_plan_report_test.mjs
run: >-
node --test
tests/release_plan_report_test.mjs
tests/release_plan_accessibility_test.mjs

- name: Build the locked Rust CLI
working-directory: zed-cli
Expand All@@ -94,31 +106,33 @@ jobs:
--input build/release-plan.json
--output build/release-plan.html

- name: Install pinned Playwright Chromium
- name: Install pinned Playwright ${{ matrix.browser }}
working-directory: zed-cli
run: |
set -euo pipefail
python -m venv .venv-release-browser
.venv-release-browser/bin/python -m pip install --disable-pip-version-check --no-cache-dir playwright==1.55.0
.venv-release-browser/bin/python -m playwright install --with-deps chromium
.venv-release-browser/bin/python -m playwright install --with-deps "${{ matrix.browser }}"

- name: Exercise the generated report in Chromium
- name: Exercise accessibility print and no-JavaScript contracts
working-directory: zed-cli
run: .venv-release-browser/bin/python tests/browser/test_release_plan_report.py
env:
PLAYWRIGHT_BROWSER: ${{ matrix.browser }}
run: .venv-release-browser/bin/python tests/browser/test_release_plan_cross_browser.py

- name: Upload the reviewed offline report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: zed-release-plan-report
name: zed-release-plan-report-${{ matrix.browser }}
path: zed-cli/build/release-plan.html
if-no-files-found: error
retention-days: 7

- name: Upload browser diagnostics
- name: Upload ${{ matrix.browser }} browser diagnostics
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: zed-release-plan-browser-diagnostics
name: zed-release-plan-${{ matrix.browser }}-diagnostics
path: zed-cli/build/release-plan-browser-results
if-no-files-found: ignore
retention-days: 7
21 changes: 19 additions & 2 deletions docs/release-plan-report.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,22 @@ zed release plan --json | \
node scripts/render-release-plan-html.mjs --output build/release-plan.html
```

Open `build/release-plan.html` directly from disk. The report includes source provenance, exact Zed/native/forge counts, artifact tables, empty states, and client-side filtering. Press `Escape` while the filter is focused to clear it and restore the complete plan.
Open `build/release-plan.html` directly from disk. The report includes source provenance, exact Zed/native/forge counts, artifact tables, explicit empty states, and progressive client-side filtering. Press `Escape` while the filter is focused to clear it and restore the complete plan.

## Accessible review

- A visible-on-focus skip link moves keyboard users directly to the report.
- The filter is associated with instructions and its live result status.
- Every artifact table has a descriptive caption in addition to its visible section heading.
- Heading order and semantic main/section/table landmarks remain stable.
- With JavaScript disabled, filtering is hidden and every release artifact remains readable.
- Forced-colors styling retains visible borders and focus indication.

## Printing and PDF review

Use the browser's print command to archive or review the release plan. Print media intentionally hides the filter and skip link, restores every artifact row even when the screen view is filtered, repeats table headers, avoids splitting rows where practical, and retains provenance plus destination counts.

The generated report remains a single local HTML file. Printing does not fetch remote fonts, styles, scripts, images, or analytics.

## Security properties

Expand All@@ -30,4 +45,6 @@ Open `build/release-plan.html` directly from disk. The report includes source pr

## Validation

`tests/release_plan_report_test.mjs` checks determinism, schema validation, hostile-value escaping, counts, empty states, CSP construction, strict argument handling, atomic replacement, and symbolic-link refusal. `tests/browser/test_release_plan_report.py` generates a real plan through the Rust CLI and verifies the rendered artifact in Chromium, including filtering, Escape reset, keyboard focus, narrow layout, console/page errors, and external network requests.
`tests/release_plan_report_test.mjs` checks determinism, schema validation, hostile-value escaping, counts, empty states, CSP construction, strict argument handling, atomic replacement, and symbolic-link refusal. `tests/release_plan_accessibility_test.mjs` locks skip navigation, table captions, progressive enhancement, print restoration, and forced-colors rules.

The GitHub Actions browser matrix generates a real release plan through the locked Rust CLI and verifies the same offline file in Chromium, Firefox, and WebKit. It covers filtering and Escape reset, keyboard order, accessible names and captions, print media, no-JavaScript readability, forced-colors behavior, narrow layouts, console/page errors, and external network requests.
39 changes: 36 additions & 3 deletions scripts/render-release-plan-html.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,10 @@ const STYLE = `
:root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.5}
*{box-sizing:border-box}
body{margin:0;background:Canvas;color:CanvasText}
.skip-link{position:absolute;left:.75rem;top:.75rem;z-index:10;padding:.6rem .8rem;background:Canvas;color:CanvasText;border:2px solid CanvasText;border-radius:.4rem;transform:translateY(-200%)}
.skip-link:focus{transform:translateY(0)}
.visually-hidden{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}
.no-js .filter-controls{display:none}
main{width:min(88rem,100%);margin:0 auto;padding:clamp(1rem,4vw,3rem)}
header,.panel{border:1px solid color-mix(in srgb,CanvasText 22%,transparent);border-radius:.9rem;padding:clamp(1rem,3vw,1.5rem);margin-block:1rem}
.eyebrow{margin:0;font-size:.78rem;font-weight:750;letter-spacing:.09em;text-transform:uppercase}
Expand All@@ -19,10 +23,12 @@ h1,h2{line-height:1.2;overflow-wrap:anywhere}
.metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.8rem}
.metric span{font-size:clamp(1.6rem,5vw,2.5rem);font-weight:800}
label{display:block;font-weight:700;margin-bottom:.4rem}
.filter-help{margin:.35rem 0 .65rem}
input{width:100%;min-height:2.75rem;padding:.65rem .75rem;border:1px solid color-mix(in srgb,CanvasText 28%,transparent);border-radius:.55rem;background:Canvas;color:CanvasText;font:inherit}
input:focus-visible,a:focus-visible{outline:.2rem solid Highlight;outline-offset:.15rem}
.table-wrap{overflow-x:auto;border:1px solid color-mix(in srgb,CanvasText 20%,transparent);border-radius:.65rem}
table{width:100%;border-collapse:collapse;min-width:44rem}
caption{text-align:left;font-weight:700;padding:.7rem}
th,td{text-align:left;vertical-align:top;padding:.7rem;border-bottom:1px solid color-mix(in srgb,CanvasText 14%,transparent)}
th{font-size:.78rem;letter-spacing:.05em;text-transform:uppercase;background:color-mix(in srgb,Canvas 92%,CanvasText 8%)}
tbody tr:last-child td{border-bottom:0}
Expand All@@ -31,14 +37,35 @@ code{overflow-wrap:anywhere;word-break:break-word}
.empty{text-align:center;font-style:italic}
footer{padding:1rem 0;font-size:.9rem}
@media(max-width:42rem){.metrics{grid-template-columns:1fr}.panel,header{border-radius:.65rem}table{min-width:36rem}}
@media(forced-colors:active){header,.panel,.provenance li,.metric,.table-wrap,input{border-color:CanvasText}.provenance li,.metric,th{background:Canvas}}
@page{margin:12mm}
@media print{
:root{color-scheme:light}
body{background:#fff;color:#000;font-size:10pt}
main{width:100%;padding:0}
.skip-link,.filter-controls,noscript{display:none!important}
header,.panel{border:0;border-radius:0;padding:0;margin:0 0 1rem}
.provenance{grid-template-columns:repeat(2,minmax(0,1fr))}
.provenance li,.metric{background:#fff;border:1px solid #000}
.table-wrap{overflow:visible;border:0}
table{min-width:0;font-size:8.5pt}
caption{padding:.3rem 0}
thead{display:table-header-group}
tr,.provenance li,.metric{break-inside:avoid}
[hidden]{display:table-row!important}
th{background:#fff;color:#000}
footer{border-top:1px solid #000;margin-top:1rem}
}
`;

const SCRIPT = `
(() => {
"use strict";
document.documentElement.classList.remove("no-js");
const input = document.querySelector("#artifact-filter");
const status = document.querySelector("#filter-status");
const rows = [...document.querySelectorAll("tbody tr[data-search]")];
input.disabled = false;
const apply = () => {
const query = input.value.trim().toLocaleLowerCase();
let visible = 0;
Expand DownExpand Up@@ -167,6 +194,7 @@ function tableSection({ id, title, description, headers, rows, empty }) {
<h2 id="${id}-heading">${escapeHtml(title)}</h2>
<p>${escapeHtml(description)}</p>
<div class="table-wrap"><table data-kind-table="${escapeHtml(id)}">
<caption class="visually-hidden">${escapeHtml(title)}. ${escapeHtml(description)}</caption>
<thead><tr>${headers.map((header) => `<th scope="col">${escapeHtml(header)}</th>`).join("")}</tr></thead>
<tbody>${body}</tbody>
</table></div>
Expand DownExpand Up@@ -228,7 +256,7 @@ export function renderReleasePlan(input) {
].join("; ");

return `<!doctype html>
<html lang="en">
<html lang="en" class="no-js">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
Expand All@@ -237,7 +265,8 @@ export function renderReleasePlan(input) {
<style>${STYLE}</style>
</head>
<body>
<main>
<a class="skip-link" href="#release-report">Skip to release report</a>
<main id="release-report" tabindex="-1">
<header>
<p class="eyebrow">Credential-free release review</p>
<h1>${escapeHtml(plan.release_set)}</h1>
Expand All@@ -257,10 +286,14 @@ export function renderReleasePlan(input) {
<div class="metric" data-count-kind="forge"><strong>Forge mirrors</strong><span>${counts.forge}</span></div>
</div>
<p><strong data-total-count>${total}</strong> total planned artifacts and mirrors.</p>
<div class="filter-controls">
<label for="artifact-filter">Filter artifacts</label>
<input id="artifact-filter" type="search" autocomplete="off" placeholder="Search target, registry, package, tag, or directory">
<p id="filter-help" class="filter-help">Search target, registry, package, tag, or directory. Press Escape to clear.</p>
<input id="artifact-filter" type="search" autocomplete="off" disabled aria-describedby="filter-help filter-status" placeholder="Search artifacts">
<p id="filter-status" role="status" aria-live="polite">Showing all ${total} artifacts.</p>
</div>
</section>
<noscript><p class="panel">JavaScript is disabled; all release artifacts remain visible.</p></noscript>
${tableSection({
id: "zed",
title: "Zed artifacts",
Expand Down
163 changes: 163 additions & 0 deletions tests/browser/test_release_plan_cross_browser.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
from __future__ import annotations

import os
from pathlib import Path

from playwright.sync_api import Browser, BrowserContext, Page, sync_playwright

ROOT = Path(__file__).resolve().parents[2]
REPORT = Path(os.environ.get("ZED_RELEASE_REPORT", ROOT / "build/release-plan.html")).resolve()
RESULTS = ROOT / "build" / "release-plan-browser-results"


def attach_guards(page: Page, external_requests: list[str], errors: list[str]) -> None:
page.on(
"console",
lambda message: errors.append(f"console: {message.text}")
if message.type == "error"
else None,
)
page.on("pageerror", lambda error: errors.append(f"pageerror: {error}"))
page.on(
"request",
lambda request: external_requests.append(request.url)
if request.url.startswith(("http://", "https://"))
else None,
)


def exercise_interactive(context: BrowserContext, engine: str) -> None:
context.tracing.start(screenshots=True, snapshots=True, sources=True)
page = context.new_page()
errors: list[str] = []
external_requests: list[str] = []
attach_guards(page, external_requests, errors)

try:
page.goto(REPORT.as_uri(), wait_until="load")
page.get_by_role("heading", name="acme/browser-report@1.2.3#v1.2.3").wait_for()
assert not page.locator("html").evaluate("element => element.classList.contains('no-js')")

page.keyboard.press("Tab")
assert page.evaluate("document.activeElement.classList.contains('skip-link')")
assert page.locator(".skip-link").is_visible()
page.keyboard.press("Tab")
assert page.evaluate("document.activeElement.id") == "artifact-filter"

assert page.locator("[data-count-kind='zed'] span").inner_text() == "2"
assert page.locator("[data-count-kind='native'] span").inner_text() == "2"
assert page.locator("[data-count-kind='forge'] span").inner_text() == "3"
assert page.locator("[data-total-count]").inner_text() == "7"
assert page.locator("tbody tr[data-search]").count() == 7
assert page.locator("table caption").count() == 3
captions = page.locator("table caption").all_inner_texts()
assert captions[0].startswith("Zed artifacts.")
assert captions[1].startswith("Native registry artifacts.")
assert captions[2].startswith("Forge package mirrors.")
assert page.locator("#artifact-filter").get_attribute("aria-describedby") == (
"filter-help filter-status"
)
assert page.locator("h1,h2").evaluate_all(
"elements => elements.map(element => element.tagName)"
) == ["H1", "H2", "H2", "H2", "H2"]

filter_input = page.get_by_label("Filter artifacts")
filter_input.fill("npm")
assert page.locator("tbody tr[data-search]:visible").count() == 4
assert "4 of 7 artifacts" in page.get_by_role("status").inner_text()

page.emulate_media(media="print")
assert page.locator(".filter-controls").evaluate(
"element => getComputedStyle(element).display"
) == "none"
assert page.locator("thead").first.evaluate(
"element => getComputedStyle(element).display"
) == "table-header-group"
assert set(
page.locator("tbody tr[data-search]").evaluate_all(
"elements => elements.map(element => getComputedStyle(element).display)"
)
) == {"table-row"}
page.emulate_media(media="screen")

filter_input.press("Escape")
assert filter_input.input_value() == ""
assert page.locator("tbody tr[data-search]:visible").count() == 7
assert page.get_by_role("status").inner_text() == "Showing all 7 artifacts."

page.emulate_media(forced_colors="active")
assert page.locator("header").evaluate(
"element => getComputedStyle(element).borderTopStyle"
) != "none"
page.emulate_media(forced_colors="none")

page.set_viewport_size({"width": 390, "height": 844})
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert page.get_by_role("main").is_visible()
assert page.get_by_role("heading", name="Forge package mirrors").is_visible()

assert not external_requests, external_requests
assert not errors, errors
context.tracing.stop()
except BaseException:
RESULTS.mkdir(parents=True, exist_ok=True)
page.screenshot(path=RESULTS / f"{engine}-interactive-failure.png", full_page=True)
context.tracing.stop(path=RESULTS / f"{engine}-interactive-trace.zip")
raise


def exercise_without_javascript(browser: Browser, engine: str) -> None:
context = browser.new_context(
java_script_enabled=False,
viewport={"width": 390, "height": 844},
)
page = context.new_page()
errors: list[str] = []
external_requests: list[str] = []
attach_guards(page, external_requests, errors)
try:
page.goto(REPORT.as_uri(), wait_until="load")
assert page.locator("html").evaluate("element => element.classList.contains('no-js')")
assert page.locator(".filter-controls").evaluate(
"element => getComputedStyle(element).display"
) == "none"
assert page.locator("noscript").is_visible()
assert "all release artifacts remain visible" in page.locator("noscript").inner_text()
assert page.locator("tbody tr[data-search]").count() == 7
assert page.locator("tbody tr[data-search]:visible").count() == 7
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert not external_requests, external_requests
assert not errors, errors
except BaseException:
RESULTS.mkdir(parents=True, exist_ok=True)
page.screenshot(path=RESULTS / f"{engine}-no-js-failure.png", full_page=True)
raise
finally:
context.close()


def main() -> None:
if not REPORT.is_file():
raise SystemExit(f"release report does not exist: {REPORT}")
engine = os.environ.get("PLAYWRIGHT_BROWSER", "chromium")
if engine not in {"chromium", "firefox", "webkit"}:
raise SystemExit(f"unsupported PLAYWRIGHT_BROWSER: {engine}")

RESULTS.mkdir(parents=True, exist_ok=True)
with sync_playwright() as playwright:
browser = getattr(playwright, engine).launch()
try:
context = browser.new_context(viewport={"width": 1280, "height": 900})
try:
exercise_interactive(context, engine)
finally:
context.close()
exercise_without_javascript(browser, engine)
finally:
browser.close()
print(f"zed release-plan {engine} accessibility and print contract passed")


if __name__ == "__main__":
main()
Loading
Loading