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
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ package-lock.json
docs/img
.wrangler
index.html
# V41 — generated from PLAN.md by `npm run changelog`; a test compares it byte for byte
CHANGELOG.md
223 changes: 223 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion PLAN.md

Large diffs are not rendered by default.

44 changes: 24 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,6 @@ contributions](docs/screenshots/insights.png)
_Every figure above was produced by the app itself, on the `titanic.csv` sample, seed 42 —
reproducible by pressing train._

**LIMITATION: The ideal dataset size is between 1MB and 30MB; beyond 30 MB, the browser response time may take longer to return the results.**

### Data Studio — `/data`

- **Quality report** with a deterministic 0–100 score: missing cells, duplicates,
Expand Down Expand Up @@ -234,14 +232,18 @@ Wikimedia Commons), the portrait is NASA, public domain._
so), slow model families train on measured, announced caps scored against the same
full test set, and parsing refuses past a named 20M-cell memory budget instead of
letting the tab die.
- **Performance.** Every section serves a prerendered static shell (hero paints before
JavaScript); Lighthouse mobile ≈ 0.99 on `/ml` under real throttling. Heavy
dependencies (Dexie, SheetJS, ONNX Runtime) load lazily.
- **Quality bar.** 711 unit tests and 111 Playwright end-to-end tests across three browser
projects — desktop, a phone viewport, and dark mode — covering offline PWA, a fake
webcam, a horizontal-overflow guard on every route, and axe-core WCAG A/AA checks on
every page including the twenty-four documentation pages. Plus strict TypeScript,
ESLint, Prettier, and Lighthouse budgets — all enforced in CI.
- **Performance.** Every page — the sections, the home page and each of the twelve
documentation URLs — serves a prerendered static shell (hero paints before JavaScript;
a doc page carries its whole article, in English until the app mounts); Lighthouse
mobile ≈ 0.99 on `/ml` under real throttling. Heavy dependencies (Dexie, SheetJS, ONNX
Runtime) load lazily.
- **Quality bar.** 817 unit tests and 122 Playwright end-to-end tests across five
projects — desktop, a phone viewport in English and in French, dark mode, and
Cloudflare Pages' own routing emulated by `wrangler pages dev` (a real 404, the security
headers as served) — covering offline PWA, a fake webcam, a horizontal-overflow guard on
every route, and axe-core WCAG A/AA checks on every page including the twenty-four
documentation pages. Plus strict TypeScript, ESLint, Prettier, and Lighthouse budgets —
all enforced in CI.
- **One dependency does not come from npm.** SheetJS left the registry, and the copy
still published there (`xlsx@0.18.5`) carries two unfixable high advisories. The
dependency points at the project's official tarball instead, which fixes both;
Expand All @@ -265,15 +267,16 @@ npm ci # install dependencies
npm run dev # start the dev server
```

| Script | Purpose |
| --------------------------------------- | ---------------------------------- |
| `npm run test` | Unit tests (Vitest) |
| `npm run e2e` | End-to-end tests (Playwright) |
| `npm run typecheck` | TypeScript, strict mode |
| `npm run lint` / `npm run format:check` | ESLint / Prettier |
| `npm run build` | Production build to `dist/` |
| `npm run preview` | Serve the production build locally |
| `npm run llm:prepare` | Fetch and split the local LLM |
| Script | Purpose |
| --------------------------------------- | -------------------------------------------------------------- |
| `npm run test` | Unit tests (Vitest) |
| `npm run e2e` | End-to-end tests (Playwright) |
| `npm run typecheck` | TypeScript, strict mode |
| `npm run lint` / `npm run format:check` | ESLint / Prettier |
| `npm run build` | Production build to `dist/` |
| `npm run preview` | Serve the production build locally |
| `npm run llm:prepare` | Fetch and split the local LLM |
| `npm run changelog` | Regenerate `CHANGELOG.md` from `PLAN.md` and align the version |

The language model behind the data assistant is **not committed** (355 MB). `npm run
llm:prepare` downloads it into `public/llm/` and splits it into parts under Cloudflare's
Expand Down Expand Up @@ -321,7 +324,8 @@ CI builds, tests and deploys on every push: pull requests get a Cloudflare Pages

Development proceeds in planned "caps" of feature waves; six caps have shipped (MVP
through the lab meeting the real world — real photos, real text, real file sizes). The full plan, delivery log and design decisions live in
[PLAN.md](PLAN.md).
[PLAN.md](PLAN.md); [CHANGELOG.md](CHANGELOG.md) is extracted from it — one entry per
wave, newest first — by `npm run changelog`, and a test fails when the two disagree.

## License

Expand Down
80 changes: 80 additions & 0 deletions e2e/routing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { expect, test } from '@playwright/test';

/**
* V41 — what Cloudflare Pages does with a URL, checked against Pages' own
* routing rather than the dev server's.
*
* `vite preview` answers every unknown path with `index.html` and a 200, and
* ignores `_redirects`, `_headers` and `404.html` — so nothing in the rest of
* the suite could tell a real 404 from a soft one. This spec runs in the
* `pages` project only, against `wrangler pages dev dist`, which applies the
* same asset routing production does.
*
* Measured on production before V41: every misspelled address answered 200,
* and the file's one rule (`/* /index.html 200`) was silently INVALID — Pages
* strips `/index.html` from URLs, so the rewrite would loop and the parser
* dropped it. The 200 came from the default single-page fallback instead.
*/
test.use({ locale: 'en-US' });

test('an unknown address answers 404 with the not-found page, not indexed', async ({ request }) => {
const response = await request.get('/this-address-does-not-exist');
expect(response.status()).toBe(404);
const html = await response.text();
expect(html).toContain('<title>Page not found · LabML</title>');
expect(html).toContain('<meta name="robots" content="noindex">');
// A 404 has no canonical: there is nothing there to be the canonical of.
expect(html).not.toContain('rel="canonical"');
});

test('a misspelled documentation slug answers 404 too', async ({ request }) => {
expect((await request.get('/docs/this-page-does-not-exist')).status()).toBe(404);
});

test('a missing asset answers 404, never HTML with a 200', async ({ request }) => {
const response = await request.get('/assets/this-chunk-does-not-exist.js');
expect(response.status()).toBe(404);
});

test('a run, a comparison and a share link answer 200 with the bare shell', async ({ request }) => {
for (const path of ['/ml/run/abc', '/ml/compare/a/b', '/ml/compare-many/a,b,c', '/ml/share']) {
const response = await request.get(path);
expect(response.status(), path).toBe(200);
const html = await response.text();
// No hero: these pages describe one visitor's local data and the app
// paints them; a hero would show the wrong content for a frame.
expect(html, path).not.toMatch(/<h1[\s>]/);
expect(html, path).toContain('<meta name="robots" content="noindex">');
// Previews still work — a share link pasted in a chat shows the site card.
expect(html, path).toContain('<meta property="og:image"');
expect(html, path).toContain('<div id="root"></div>');
}
});

test('the home page, a section and a documentation page answer 200 with their hero', async ({
request,
}) => {
const pages: [string, string][] = [
['/', 'A machine learning lab,'],
['/ml/', 'From a CSV to a leaderboard'],
[
'/docs/premier-modele',
'rel="canonical" href="https://app.dominicdapice.com/docs/premier-modele"',
],
];
for (const [path, text] of pages) {
const response = await request.get(path);
expect(response.status(), path).toBe(200);
const html = await response.text();
expect(html, path).toContain(text);
expect(html, path).toMatch(/<h1[\s>]/);
}
});

test('the security headers are served by the asset routing, not only declared', async ({
request,
}) => {
const headers = (await request.get('/')).headers();
expect(headers['content-security-policy']).toContain("default-src 'self'");
expect(headers['x-frame-options']).toBe('DENY');
});
60 changes: 57 additions & 3 deletions e2e/shells.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,58 @@ test('every shell carries its own title, description and social card', async ({
expect(og.headers()['content-type']).toContain('image/png');
});

/**
* V41 — every URL the sitemap advertises must describe itself.
*
* Measured on production (29 Sep 2026): the twelve `/docs/<slug>` pages the
* sitemap listed were all served by the root fallback — the home page's title,
* description, Open Graph tags and canonical, and an empty `<div id="root">`.
* To a crawler that is twelve duplicates of the home page; to a reader pasting
* a tutorial link into a chat, a preview of the wrong page. The V35 guard
* above checked the section shells and the sitemap's status codes, and missed
* it because a fallback answers 200 too. This one reads every advertised URL.
*/
test('every page the sitemap advertises describes itself without JavaScript', async ({
request,
}) => {
const xml = await (await request.get('/sitemap.xml')).text();
const listed = [...xml.matchAll(/<loc>https:\/\/app\.dominicdapice\.com([^<]*)<\/loc>/g)].map(
(match) => match[1],
);
expect(listed.length).toBeGreaterThanOrEqual(21);

const titles = new Map<string, string>();
for (const path of listed) {
const html = await (await request.get(path)).text();
const meta = (pattern: RegExp) => pattern.exec(html)?.[1]?.trim() ?? '';

expect(meta(/<link rel="canonical" href="([^"]*)"/), `${path} canonical`).toBe(
`https://app.dominicdapice.com${path}`,
);
expect(meta(/<meta property="og:url" content="([^"]*)"/), `${path} og:url`).toBe(
`https://app.dominicdapice.com${path}`,
);
const title = meta(/<title>([^<]*)<\/title>/);
expect(titles.has(title), `${path} repeats the title of ${titles.get(title)}`).toBe(false);
titles.set(title, path);
expect(
meta(/<meta name="description" content="([^"]*)"/).length,
`${path} description`,
).toBeGreaterThan(40);
// A heading in the HTML itself: the page exists before any script runs.
expect(html, `${path} paints nothing before JavaScript`).toMatch(/<h1[\s>]/);
}
});

test('the home page description starts where its sentence starts', async ({ request }) => {
const html = await (await request.get('/')).text();
const description = /<meta name="description" content="([^"]*)"/.exec(html)?.[1] ?? '';
// Measured on production: « entirely in your browser. Drop a dataset… » — the
// description opened mid-sentence because the half of the title before the
// highlight was left out of it.
expect(description).toMatch(/^A machine learning lab, entirely in your browser\. Drop a dataset/);
});

test('the sitemap lists every page that exists, and nothing that does not', async ({ request }) => {
const xml = await (await request.get('/sitemap.xml')).text();
const listed = [...xml.matchAll(/<loc>https:\/\/app\.dominicdapice\.com([^<]*)<\/loc>/g)].map(
Expand Down Expand Up @@ -124,9 +176,11 @@ test('the sitemap lists every page that exists, and nothing that does not', asyn
* index. There is no error and no visible symptom, which is exactly why this
* belongs in a test rather than in someone's memory.
*
* The check is on the CONTENT, never the status code: `_redirects` sends every
* unknown path to `index.html` with HTTP 200, so a missing file still answers
* 200 — with HTML. That trap cost a false positive during the V35 audit.
* The check is on the CONTENT, never the status code: `vite preview`, which
* serves this suite, answers every unknown path with `index.html` and a 200,
* so a missing file would still answer 200 — with HTML. That trap cost a false
* positive during the V35 audit. (Production answers a real 404 since V41;
* `routing.spec.ts` checks that side against Pages' own routing.)
*/
test('the Bing site verification file is served from the root', async ({ request }) => {
const response = await request.get('/BingSiteAuth.xml');
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.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "labml",
"private": true,
"version": "1.0.0",
"version": "1.41.0",
"license": "MIT",
"type": "module",
"engines": {
Expand All @@ -15,6 +15,7 @@
"llm:bench": "node scripts/run-llm-bench.mjs",
"llm:bench:node": "LABML_LLM_BENCH=1 vitest run src/features/ai/llm/bench.node.test.ts",
"llm:fetch": "node scripts/prepare-llm.mjs .llm-cache --flat",
"changelog": "node scripts/changelog.mjs",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
Expand Down
33 changes: 27 additions & 6 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default defineConfig({
// still runs once, and only the specs that can actually catch a viewport or
// a theme regression are replayed.
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'chromium', use: { ...devices['Desktop Chrome'] }, testIgnore: /routing\.spec\.ts/ },
{
name: 'mobile',
testMatch: /(layout|a11y)\.spec\.ts/,
Expand Down Expand Up @@ -58,10 +58,31 @@ export default defineConfig({
testMatch: /a11y\.spec\.ts/,
use: { ...devices['Desktop Chrome'], colorScheme: 'dark' },
},
{
// V41 — Cloudflare Pages' routing, emulated. `vite preview` serves
// `index.html` with a 200 for every unknown path and ignores
// `_redirects`, `_headers` and `404.html`, so the suite above cannot
// tell a real 404 from a soft one, nor see the headers production
// sends. `wrangler pages dev` applies the same asset routing Pages
// does; only the spec that needs it runs there.
name: 'pages',
testMatch: /routing\.spec\.ts/,
use: { ...devices['Desktop Chrome'], baseURL: 'http://127.0.0.1:8788' },
},
],
webServer: [
{
command: 'npm run preview',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
},
{
command: 'npx wrangler pages dev dist --port 8788 --ip 127.0.0.1',
url: 'http://127.0.0.1:8788/',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
// No telemetry from the test runner, and no interactive prompt.
env: { WRANGLER_SEND_METRICS: 'false', CI: '1' },
},
],
webServer: {
command: 'npm run preview',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
},
});
24 changes: 23 additions & 1 deletion public/_redirects
Original file line number Diff line number Diff line change
@@ -1 +1,23 @@
/* /index.html 200
# V41 — Cloudflare Pages applies these rules BEFORE it looks for a file
# (« redirects are always followed, regardless of whether or not an asset
# matches »), then serves the exact file if one exists (the prerendered shells,
# the documentation pages, every asset), then 404.html. So a rule must never
# overlap a real file: `/ml/* /shell 200` would hide /ml/index.html in
# production. Only the routes that have no file of their own are listed: a
# run, a comparison, a share link — pages that describe one visitor's local
# data and that the app paints. They are handed the bare shell at its clean
# URL, with a 200 so the address in the bar stays what the visitor typed.
#
# Before V41 the single rule was `/* /index.html 200`. `wrangler pages dev`
# reports it as INVALID and ignores it — Pages strips `/index.html` from URLs,
# so the rewrite would loop — and the 200 on every unknown address came from
# the default single-page fallback instead: a misspelled URL was a soft 404.
# With a 404.html in the build that fallback is off, so this list must be
# exact; `src/app/redirects.test.ts` keeps it aligned with the router, and
# `e2e/routing.spec.ts` replays it against Pages' own routing.
#
# Exact sources first, splats after — the order the Pages parser asks for.
/ml/share /shell 200
/ml/run/* /shell 200
/ml/compare/* /shell 200
/ml/compare-many/* /shell 200
12 changes: 12 additions & 0 deletions scripts/changelog.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export interface Wave {
version: string;
title: string;
summary: string;
why: string;
}

export function extractWaves(plan: string): Wave[];
export function renderChangelog(waves: Wave[]): string;
export function packageVersionFor(waves: Wave[]): string;
export function syncPackageVersion(packageJson: string, version: string): string;
export function syncLockVersion(lock: string, version: string): string;
Loading
Loading