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

# Publishes docs/ to GitHub Pages. The site is plain HTML with inline CSS and
# no external requests, so there is nothing to build — the job uploads the
# directory as it stands in the repository.
#
# This does nothing until Pages is switched on once, by hand:
# Settings -> Pages -> Build and deployment -> Source: GitHub Actions
# Until then the deploy step fails with "Pages is not enabled", which is the
# intended state for a repository that has not decided to publish yet.

on:
push:
branches: [main]
paths:
- 'docs/**'
- '.github/workflows/pages.yml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

# One deploy at a time, and never cancel one that is already running: a
# half-published site is worse than a slightly stale one.
concurrency:
group: pages
cancel-in-progress: false

jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}

steps:
- uses: actions/checkout@v4

- name: Check the site loads nothing from the network
# Outbound <a href> links are fine and expected. A remote *asset* is
# not: a CDN script, stylesheet, font or image makes the page depend on
# someone else's uptime, and it fails quietly for whoever has it cached.
# Cheaper to fail here than to find out from a reader.
run: |
if grep -rInE '(<script[^>]+src=|<link[^>]+href=|<img[^>]+src=|@import|url\()["'"'"']?https?://' \
docs --include='*.html' --include='*.css' --include='*.svg'; then
echo "::error::docs/ loads an asset from the network. Inline it instead."
exit 1
fi

- uses: actions/configure-pages@v5

- uses: actions/upload-pages-artifact@v3
with:
path: docs

- id: deploy
uses: actions/deploy-pages@v5
134 changes: 134 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# AGENTS.md

Orientation for an agent landing in this repository cold. `CLAUDE.md` is a
symlink to this file — edit this one.

## What this is

`webcodecs-census` finds leaked WebCodecs objects in a browser app and hands
back the line of code that allocated them. WebCodecs objects hold resources
from a finite pool outside the JS heap; GC never reclaims them, only `close()`
does, and nothing in the platform tells you that you leaked one. The hard part
is that decoders live in Web Workers, which page-level monkey-patching cannot
reach — so the exact path drives Chrome over the DevTools Protocol and injects
into each worker at a `beforeScriptExecution` pause, before its first line.

Public repository, published to npm as three packages under `@motionvector/`.

| Path | What |
| --- | --- |
| `packages/core` | The instrumentation and the assertion API. No dependencies. Also builds the injectable IIFE shim. |
| `packages/cdp` | Injects the shim into a running Chrome — page, iframes, workers — over CDP. |
| `packages/mcp` | An MCP server wrapping the above, so an agent can use it. |
| `extension/` | A Chrome MV3 extension. Patch mode (no debugger) and exact mode (`chrome.debugger`). |
| `docs/` | The documentation site. Plain HTML, no build step. See `docs/README.md`. |
| `test/` | Real-browser tests. No mocks. |
| `scripts/` | Release plumbing: `version.mjs`, `preflight.mjs`, `publish.mjs`, `release-notes.mjs`, `make-diagram.mjs`. |

## The one rule that matters

**A change must not let this tool report "no leaks" for an app that is
leaking.** Everything else is style. A leak detector that silently sees nothing
is worse than no leak detector, because it converts an open question into a
wrong answer. `CONTRIBUTING.md` has the long version.

Several things exist only to protect that property, and they are not
refactoring targets:

- `installCensus()` wraps each patch step separately and records failures in
`problems[]` instead of throwing.
- Patch mode reports every worker it could not wrap, with the reason.
- `checkLeaks()` never lets `types` filter away `collectedUnclosed`, and never
prints an unqualified all-clear while an unenforced type holds live objects.
- `test/platform-assumptions.test.mjs` asserts the undocumented Chrome
behaviour injection depends on, so a browser change is reported as a browser
change.

The corollary matters too: the tool must not invent leaks either. A codec the
platform closed after an error is not a leak, and reporting it as one would be
the same class of failure pointed the other way.

If you add a path that can silently observe less than it appears to, add the
counter or the `problems[]` entry that makes it visible.

## Build and test

```bash
npm install
npm run build:all # core + cdp + mcp, then the extension, then test fixtures
npm test # node --test over test/*.test.mjs
npm run typecheck # tsc over all three packages; needs npm run build first
```

Tests drive a **real Chrome** — there are no mocks, deliberately. A mocked
`VideoDecoder` would have hidden every bug worth finding here. They find a
Chrome for Testing in the Puppeteer cache, or you point at one:

```bash
CHROME_PATH="$HOME/.cache/puppeteer/chrome/mac_arm-151.0.7922.71/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" npm test
```

Never the user's own Chrome. Instrumenting that would touch their profile and
their session.

### The git-worktree trap

**Run `npm ci` inside the worktree before trusting a `test` or `typecheck`
result.** A worktree with no `node_modules` of its own resolves
`@motionvector/*` upwards to the main checkout, so:

- `typecheck` checks `mcp` against whatever declarations are built over there —
reporting errors against code that is fine, or missing errors in code that is
not.
- The tests import their entry points by relative path, but `packages/cdp`
loads the shim by package name, so a run in a bare worktree tests the *main
checkout's* shim, not yours.

`ls -la node_modules/@motionvector` should show symlinks pointing back into the
worktree you are standing in.

## Releasing

Maintainers only, and all three packages move together — `-cdp` and `-mcp`
depend on an **exact** version of the core, so bumping one alone publishes a
package whose dependency does not exist.

```bash
node scripts/version.mjs minor # or patch / major / an explicit version
git commit -am "release: v0.3.0" && git tag v0.3.0
git push origin main --tags
```

`scripts/version.mjs` moves the three `package.json` versions, rewrites the
cross-dependencies, rewrites the two version strings that live in source
(`VERSION` in `packages/core/src/census.ts` and the name the MCP server
announces in `packages/mcp/src/index.ts`), and promotes the `Unreleased`
section of `CHANGELOG.md`. It refuses a dirty tree, and it fails loudly rather
than silently if a version pattern stops matching — those two stamps shipped
two releases stale once because nothing rewrote them.

The `v*` tag triggers `.github/workflows/release.yml`, which refuses a tag that
disagrees with the packages, runs the full browser suite against pinned Chrome
*before* publishing, then publishes all three in dependency order over OIDC
trusted publishing with provenance, and opens a GitHub Release from that
version's changelog section with the packed extension attached. There is no npm
token.

## Verifying, not asserting

Claims in this repository are measured. The overhead figures, the globals
table, the comparison against heap snapshots — each came from a run, not from
reasoning about what ought to happen. Hold new claims to that. If you cannot
measure it, write down that you could not, or leave it out.

## Style

Short sentences, active voice, no filler. Comments explain *why*, particularly
where the reason is a platform quirk that will look like a mistake to the next
reader. Do not add comments that restate the code.

## Publishing anything public

This is a public repository. Do not push, open a pull request, publish to npm,
or enable GitHub Pages without the maintainer saying so in the conversation.
Draft it, then hand it over.
1 change: 1 addition & 0 deletions CLAUDE.md
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

**Find leaked `VideoFrame`s, `AudioData` and codecs in a WebCodecs app — including inside Web Workers — and get the line of code that allocated them.**

[**Documentation**](https://motionvector-dev.github.io/webcodecs-census/) &nbsp;·&nbsp;
[Why it's hard](#why-this-is-hard-and-why-it-didnt-exist) &nbsp;·&nbsp;
[Use from an agent](#use-it-from-an-agent) &nbsp;·&nbsp;
[Use in CI](#use-it-in-ci) &nbsp;·&nbsp;
Expand Down Expand Up @@ -160,7 +161,7 @@ import { expectNoLeakedFrames } from '@motionvector/webcodecs-census';

test('the editor releases every frame it decodes', async () => {
await playThroughTimeline();
expectNoLeakedFrames(await session.census(), { minAgeMs: 1000 });
expectNoLeakedFrames(await session.census(), { allow: { VideoFrame: 2 } });
});
```

Expand Down Expand Up @@ -249,6 +250,14 @@ The core makes no network requests of any kind and has no runtime dependencies.
- Patch mode changes `self.location` inside wrapped workers to the loader blob URL. Workers using `import.meta.url` are unaffected; workers building paths from `self.location` are not.
- Exact mode cannot share a tab with an open DevTools window. Chrome allows one debugger client.

## Documentation

The full reference lives at
**[motionvector-dev.github.io/webcodecs-census](https://motionvector-dev.github.io/webcodecs-census/)**
— the assertion API field by field, the CDP driver, the MCP server, the
extension, CI recipes, and the limits above in more detail. Its source is
[`docs/`](./docs), which is plain HTML with no build step.

## Development

```bash
Expand Down
Empty file added docs/.nojekyll
Empty file.
48 changes: 48 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# docs/

The documentation site, and the diagram the root README uses.

| Path | What |
| --- | --- |
| `index.html` | What the tool is, the problem, and why the worker case is hard |
| `quickstart.html` | Install and a first leak report, three ways |
| `api.html` | The core and assertion API, field by field |
| `cdp.html` | The CDP driver |
| `mcp.html` | The MCP server |
| `extension.html` | The browser extension, patch mode and exact mode |
| `ci.html` | CI recipes |
| `limits.html` | What it cannot see |
| `injection-*.svg` | The two-phase injection diagram, one file per theme. Generated by `npm run build:diagram`; also embedded in the root README |
| `.nojekyll` | Serve the directory as-is rather than through Jekyll |

## Publishing

`.github/workflows/pages.yml` uploads this directory unchanged. Nothing is
built, so what is committed is exactly what is served — open any page with
`file://` and it works.

Pages has to be switched on once by hand, under **Settings → Pages → Source:
GitHub Actions**. Until then the deploy step fails, which is the right state
for a repository that has not decided to publish.

## Editing

Two rules, both of which the workflow enforces or the pages depend on.

**Nothing may be loaded from the network.** No CDN scripts, no remote fonts, no
external stylesheets. Outbound `<a href>` links are fine; a remote *asset* is
not, because it makes the page depend on someone else's uptime and fails
quietly for anyone who has it cached. The deploy workflow greps for this and
fails the build.

**Every page carries the same `<style>` block.** It is duplicated on purpose —
that is what keeps the site buildless — so a style change means replacing that
block in all eight files, not editing one. The block runs from the `:root`
palette to the reduced-motion query and is byte-identical everywhere, so a
mechanical replace is safe.

Themes are three-state: the bare `:root` palette is light, a
`prefers-color-scheme: dark` block guarded with `:not([data-theme="light"])`
handles the System setting, and an explicit `[data-theme="dark"]` block lets the
toggle win in both directions. Do not give a colour its only definition inside
one of those blocks.
Loading
Loading