diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md
index 6702f45a..e5fdab9a 100644
--- a/telemetry-worker/README.md
+++ b/telemetry-worker/README.md
@@ -46,9 +46,9 @@ genclass.dev/dashboard/ --> dashboard page --GET /api/projects/?
| file | what |
|---|---|
| `src/index.ts` | entry point: default export only (`fetch`, `scheduled`). The Workers runtime treats every named export of the main module as an entrypoint, so nothing else is exported here. |
-| `src/collector.ts` | ingest (`POST /v1/events`), envelope validation (incl. optional `token`), routing for `/api/*`, `/start`, `/dashboard*`; hands account paths to `src/auth.ts` |
+| `src/collector.ts` | ingest (`POST /v1/events`), envelope validation (incl. optional `token`), routing for `/api/*`, `/start`, `/start/open-source`, `/dashboard*`; hands account paths to `src/auth.ts` |
| `src/dashboard.ts` | ids and hashing, project creation and lookup, token cache, aggregation of a batch into counters, stats for the dashboard, retention |
-| `src/pages.ts` | HTML/CSS/JS for `/start` and `/dashboard/`, and the shell and dashboard the account pages reuse (`/account/apps/` is the dashboard in its account mode); the dashboard's views, its Connect dialog (Framework, API Keys, MCP, Analytics), the MCP view and the agent link box, Copy prompt and Copy as Markdown; inline, per-response CSP nonce, fonts from Google Fonts only |
+| `src/pages.ts` | HTML/CSS/JS for `/start`, `/start/open-source` and `/dashboard/`, and the shell and dashboard the account pages reuse (`/account/apps/` is the dashboard in its account mode); the dashboard's views, its Connect dialog (Framework, API Keys, MCP, Analytics), the MCP view and the agent link box, Copy prompt and Copy as Markdown; inline, per-response CSP nonce, fonts from Google Fonts only |
| `src/studio.ts` | the Supabase Studio frame the dashboards and the organization pages use: the 48px header (organization and project switchers, Connect, Feedback, search, help, the account menu), the icon rail that widens on hover, product menus, the dialog and tabs, the command menu (Ctrl/Cmd K), Studio's dark tokens and icons; the copy helpers every page shares |
| `src/escape.ts` | `esc` and `embed`, for every page module |
| `src/auth.ts` | accounts: Google, email and password, GitHub, passkeys (sign-in, the passkey step, fresh confirmations), sessions, CSRF, the device flow, owned apps, their routes and their retention |
@@ -80,6 +80,7 @@ genclass.dev/dashboard/ --> dashboard page --GET /api/projects/?
| `GET /api/projects/?range=24h\|7d\|30d` | dashboard stats JSON (every plan includes the dashboard; a plan file that left it out would answer 402 `{ ok:false, error, locked:true }`) (default `7d`): `name, token, created, lastEvent, range, from, generated, totals, breakdowns{metric: [{key,count}]}` (top 25 each; 200 for `detect_issue` and `possible_issue`), `distinct{metric: n}` (distinct keys before that cut, e.g. pages with a problem), `series` (24 hourly or 7/30 daily buckets: sessions, decisions, detections, acted, possible, ix_views, ix_sessions), `recent` (last 50 detections/interventions in the window; possible ones are not listed). `totals` also has `latencyAvgMs`, `modelLoadAvgMs`, `failOpens`, `modelErrors`, `interventions`, `actionLimits`, `detectionsWithPage` and `possibleWithPage`. Apps that turned on interaction analytics (runtime `interactions` events, [docs/runtime/ANALYTICS.md](../docs/runtime/ANALYTICS.md)) also get `ix_views`, `ix_engaged_ms`, `ix_click`, `ix_rage` and `ix_dead` totals and breakdowns (by route, or route TAB element key; `ix_scroll` by route TAB milestone; top 200 each), counted with their own per-batch budget; and per visit `ix_sessions`, `ix_ref`, `ix_utm_source`, `ix_utm_medium`, `ix_utm_campaign`, `ix_device`, `ix_browser`, `ix_os`, `ix_country`, `ix_returning`, plus `visits` (`sessions, pages, bounces, entry[], exit[]` over the visits that started in the period) and `funnels` (`[{ name, steps, counts }]`; docs/runtime/ANALYTICS.md section 3.1); with navigation paths (ANALYTICS.md section 11) also `ix_entry` and `ix_exit` (totals and by route), `ix_nav` and `ix_nav_ms` (from TAB to), `ix_arrive` (by `nav`, and route TAB `nav`), `ix_first` (route TAB element key), `ix_first_n` and `ix_first_ms` (by route) and `ix_path` (the first 5 routes of a page load, joined with ` > `). 404 `{ ok:false, error:"not found" }` for an unknown or malformed secret. Same-origin only (no CORS). |
| `PUT /api/projects//funnels` | body `{ funnels: [{ name, steps }] }` (application/json, at most 4 KB): replaces the app's funnels (at most 5, 2 to 6 routes each; a step ending in `*` matches every route under it). 200 `{ ok, funnels }`, 400 with the reason, 403 for the sample. Account mode: `PUT /api/account/projects//funnels` with the browser session and the site's Origin |
| `GET /start` | "Get a token" page |
+| `GET /start/open-source` | "Use GenClass without an account": the open-source parts only (the runtime, the agent link, the MCP server and the local page check, all Apache-2.0), with no account, no token and `telemetry: false`, so nothing reaches genclass.dev; for teams that cannot sign up for or send data to a hosted dashboard. Choices (Guard and Eager, Findings in our own logs: the `console-json` sink, Serve the model ourselves: `fetch-model`, Deploy when done) rewrite one copied prompt (`openSourcePrompt`, run from its own source in the page, as `installPrompt` is); the page sends no request. Linked from `/start` and under the sign-in ways on `/login` and `/signup` |
| `POST /api/projects//agent` | the MCP server's log of the closed loop, mirrored, when the developer sets `GENCLASS_REPORT` for `@genclass/mcp` (docs/runtime/AGENT.md section 4.5): `{ schema: "genclass-agent/1", events: [{ t: "found" \| "explain" \| "armed" \| "replay" \| "test" \| "explore" \| "cleared", at, id?, kind?, route?, reproduced?, runs?, races?, what? }] }`, at most 50 events and 16 KB a request, rate limited per token (`SESSION_LIMIT`). Every accepted event is one row of `agent_log` (at most 500 per app, the oldest go first; unknown types are dropped); the ones about a finding also update `agent_reports` (kind, normalised route, found, explained, last replay and verdict, how many replays reproduced it, tested, proven: a replay that stops reproducing after one that did). 90 days; the stats carry them as `agent` (`findings`, `counts`, `stages`: found, explained, reproduced, fixed, closed, open, continued; `log`, the last 50) and the dashboard's Closed-loop development view shows them. The sample and unknown secrets 404; other methods 405. Ids, kinds, routes, verdicts, counts and times only, never page text |
| `GET /dashboard/` | the dashboard (for an app with no account too: it is on Free), in the Supabase Studio frame (`src/studio.ts`), in views the rail switches between: Project overview (the name and status, the summary, four stats, the problems chart, usage cards per hour or day, Get connected, the problems that need attention with a Copy prompt each and Fix them all, and Getting started until the first event), Problems (each kind with its pages, its fix and a Copy prompt for a coding agent; Fix them all copies one prompt for every kind and the dev page's checks, in which the agent says what to do on the page and waits for the loads itself), Pages and Actions, Activity, Visitors (without interaction data, under the copy-prompt button, the runtime analytics need, `ANALYTICS_RUNTIME` 0.3.0-beta.1, with the install line and the app's older versions from the `runtime` breakdown; the Connect dialog says the same), Health, MCP (the @genclass/mcp setup per coding agent, with GENCLASS_DASHBOARD set to the agent link, a Copy prompt, the tools, the JSON address), Closed loop (Closed-loop development: Fig. 3, every issue's path, found, reproduced, fix proven, closed, or round again, with the period's counts from the agent's reports; Table 15, the agent's log mirrored from `@genclass/mcp`; Fig. 6, the app's problems and what the agent does with each), Integrations (forwarding, account mode) and Project Settings (general, the key, agent access; Export in account mode). Problems, Visitors and Project Settings have Studio's product menu. The header's Connect opens the Connect dialog (also `#connect`); its Framework tab's prompt (`installPrompt`: the runtime, or the script tags of a site with no build step, the state in one atom per response, the agent link, the MCP server) follows the choices above it, Guard (and Eager), Badge on the live site, Connect my coding agent to production (makes the agent link, or names a private link) and Deploy when done, by running `installPrompt`'s own source in the page; the hash names the view (`#found`, `#recent-s`, `#mcp`); every data view copies as Markdown; and without the script every section shows. On a private link the page's own address is the agent link. With a plan file that left the dashboard out, the locked page (sign in, add the app to the account, see the plans; `src/locked.ts`). 404 page for an unknown secret; `GET /dashboard` redirects to `/start`. `/dashboard/sample` stays open |
@@ -407,7 +408,7 @@ The daily cron also deletes expired challenges and device codes and expired or r
|---|---|---|---|
| `AUTH_ORIGIN` | var | `https://genclass.dev` | expected `Origin`, WebAuthn origin, OAuth redirect URIs, cookie `Secure` |
| `AUTH_RP_ID` | var | `genclass.dev` | WebAuthn RP ID |
-| `RUNTIME_ON_NPM` | var (wrangler.toml) | `0.3.0-beta.3` | the newest `@genclass/runtime` on npm, on any dist-tag; below `0.3.0-beta.0` the Connect dialog, `/start` and private dashboards say first that keys and `login` are not published yet. Set it with `src/pages.ts` `SNIPPET_RUNTIME` to every release once release.yml has published it and jsDelivr serves it, then deploy; at `0.3.0` also set `RUNTIME_PKG` (the snippets pin the script tag to `SNIPPET_RUNTIME` and name the `@beta` dist-tag in their commands while it is a prerelease) |
+| `RUNTIME_ON_NPM` | var (wrangler.toml) | `0.3.0-beta.4` | the newest `@genclass/runtime` on npm, on any dist-tag; below `0.3.0-beta.0` the Connect dialog, `/start` and private dashboards say first that keys and `login` are not published yet. Set it with `src/pages.ts` `SNIPPET_RUNTIME` to every release once release.yml has published it and jsDelivr serves it, then deploy; at `0.3.0` also set `RUNTIME_PKG` (the snippets pin the script tag to `SNIPPET_RUNTIME` and name the `@beta` dist-tag in their commands while it is a prerelease) |
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | secret | unset | Google sign-in, the first way in; both needed. An email and a password are offered either way |
| `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | secret | unset | GitHub sign-in; both needed |
diff --git a/telemetry-worker/src/account-pages.ts b/telemetry-worker/src/account-pages.ts
index 53f7c6ef..455beb95 100644
--- a/telemetry-worker/src/account-pages.ts
+++ b/telemetry-worker/src/account-pages.ts
@@ -272,9 +272,12 @@ export interface AuthPageOptions {
notice?: string | null;
}
-/** Under the sign-in ways: the runtime needs no account, and what an account's Free plan gives (billing/plans.json). */
+/**
+ * Under the sign-in ways: the runtime needs no account (the way in without one: /start/open-source, pages.ts
+ * openSourcePage), and what an account's Free plan gives (billing/plans.json).
+ */
const optional = (): string =>
- `
The runtime needs no account. ${entitled(FREE, "dashboard") ? "The dashboard is free for every app." : "Dashboards come with a paid plan."}
`;
+ `
The runtime needs no account: set it up without one. ${entitled(FREE, "dashboard") ? "The dashboard is free for every app." : "Dashboards come with a paid plan."}
`;
/** The passkey sign-in button's script: sign in, then go to `next` (or reload, on the device page). */
const PASSKEY_LOGIN_JS = `
diff --git a/telemetry-worker/src/collector.ts b/telemetry-worker/src/collector.ts
index 86e1c83d..525d179a 100644
--- a/telemetry-worker/src/collector.ts
+++ b/telemetry-worker/src/collector.ts
@@ -11,8 +11,9 @@
// OPTIONS /v1/events (CORS preflight), GET /v1/health.
//
// Per-app tokens and dashboards (src/dashboard.ts, src/pages.ts; also routed on genclass.dev/{api/*,start*,dashboard*}):
-// POST /api/projects, GET /api/projects/, GET /start, GET /dashboard/. A batch with a known top-level
-// `token` is also aggregated into D1 for that app's dashboard; without one it is stored in R2 only, exactly as before.
+// POST /api/projects, GET /api/projects/, GET /start, GET /start/open-source (no account, no token), GET
+// /dashboard/. A batch with a known top-level `token` is also aggregated into D1 for that app's dashboard;
+// without one it is stored in R2 only, exactly as before.
// Every plan includes the dashboard (billing/plans.json), so a private link opens it, also for an app with no account
// (Free). A plan file that left the dashboard out would open the locked page instead (src/locked.ts) and its stats
// would answer 402. Collection never depends on it: every known token is aggregated.
@@ -42,7 +43,7 @@ import {
import { routeBilling } from "./billing.js";
import { json, readCapped } from "./http.js";
import { afterBatch } from "./paid.js";
-import { dashboardPage, notFoundPage, ownEndpoint, securityHeaders, startPage } from "./pages.js";
+import { dashboardPage, notFoundPage, openSourcePage, ownEndpoint, securityHeaders, startPage } from "./pages.js";
import { lockedLink } from "./locked.js";
import { hasDashboard, projectAccess } from "./plans.js";
import { SAMPLE_PROJECT, SAMPLE_SECRET, sampleStats } from "./sample.js";
@@ -406,6 +407,13 @@ async function routeSite(request: Request, url: URL, path: string, env: Env, opt
return html(200, startPage(nonce, { runtimeReady: runtimeHasTokens(env), endpoint: ownEndpoint(env) }), nonce);
}
+ // the open-source parts with no account and no token (pages.ts openSourcePage): static, nothing stored
+ if (path === "/start/open-source") {
+ if (m !== "GET" && m !== "HEAD") return null;
+ const nonce = randomBase62(22);
+ return html(200, openSourcePage(nonce), nonce);
+ }
+
if (path === "/dashboard") {
if (m !== "GET" && m !== "HEAD") return null;
return new Response(null, { status: 302, headers: { location: "/start", "cache-control": "no-store" } });
diff --git a/telemetry-worker/src/pages.ts b/telemetry-worker/src/pages.ts
index dd60f1ec..d1e44caa 100644
--- a/telemetry-worker/src/pages.ts
+++ b/telemetry-worker/src/pages.ts
@@ -1,5 +1,5 @@
-// HTML for https://genclass.dev/start (get a token) and https://genclass.dev/dashboard/ (the dashboard), and
-// the shell and dashboard the account pages (src/account-pages.ts) reuse: /account/apps/ is this dashboard in its
+// HTML for https://genclass.dev/start (get a token), /start/open-source (the open-source parts, no account) and
+// https://genclass.dev/dashboard/ (the dashboard), and the shell and dashboard the account pages (src/account-pages.ts) reuse: /account/apps/ is this dashboard in its
// account mode (the account's projects in the header's switcher, the Connect dialog's ways in, the agent link, stats
// from the account API). Every plan includes the dashboard (billing/plans.json); a plan file that left it out would
// give the locked account mode (the key and the Connect dialog, the plans, no stats), and a locked private link is
@@ -25,7 +25,7 @@ export const ANALYTICS_RUNTIME = "0.3.0-beta.1";
* set SNIPPET_RUNTIME to "0.3.0" and RUNTIME_PKG to "@genclass/runtime" (and RUNTIME_ON_NPM in wrangler.toml), then
* deploy.
*/
-export const SNIPPET_RUNTIME = "0.3.0-beta.3";
+export const SNIPPET_RUNTIME = "0.3.0-beta.4";
export const RUNTIME_PKG = "@genclass/runtime@beta";
export const CDN_SCRIPT = `https://cdn.jsdelivr.net/npm/@genclass/runtime@${SNIPPET_RUNTIME}`;
/** What local and private hosts do, as the runtime behaves (src/telemetry/config.ts) and PRIVACY.md says. */
@@ -293,7 +293,8 @@ export function startPage(nonce: string, opts: { runtimeReady?: boolean; endpoin
Get a token for your web app
One token per app. Add it to GenClass and every page load, detection and fix is collected for the
app's private dashboard. The dashboard is free.
`;
const install = installPrompt(token, endpoint);
const ch = c.choices;
- const opt = (k: string, label: string, note: string, sub = false) =>
- ``;
+ const opt = choiceOpt;
const agentNote = !ch?.agent
? ""
: ch.agent === "this"
diff --git a/telemetry-worker/src/studio.ts b/telemetry-worker/src/studio.ts
index d89b04d9..f329b5fb 100644
--- a/telemetry-worker/src/studio.ts
+++ b/telemetry-worker/src/studio.ts
@@ -305,12 +305,17 @@ function gcSelect(node){
// Copy text with the Clipboard API; when the browser refuses (no secure context, the document not focused, an embedded
// page), copy through a temporary textarea and execCommand, which works on the click itself; failing both, select the
// text in node so the person can copy it by hand (a textarea fallback is shown selected: one keystroke copies it).
+// Focus goes back where it was (the button), so a keyboard user's next Tab continues from there.
function gcLegacyCopy(text){
+ var prev = document.activeElement, ta = null;
try {
- var ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.top = "0"; ta.style.left = "0"; ta.style.opacity = "0";
- document.body.appendChild(ta); ta.focus(); ta.select(); ta.setSelectionRange(0, text.length);
- var ok = document.execCommand("copy"); document.body.removeChild(ta); return ok;
+ ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.top = "0"; ta.style.left = "0"; ta.style.opacity = "0";
+ document.body.appendChild(ta); ta.focus({ preventScroll: true }); ta.select(); ta.setSelectionRange(0, text.length);
+ return document.execCommand("copy");
} catch (e) { return false; }
+ finally {
+ try { if (ta && ta.parentNode) ta.parentNode.removeChild(ta); if (prev && prev.focus && prev !== document.body) prev.focus({ preventScroll: true }); } catch (e) {}
+ }
}
function gcCopy(b, text, node){
var label = b.getAttribute("data-label") || b.textContent;
diff --git a/telemetry-worker/test/auth.test.ts b/telemetry-worker/test/auth.test.ts
index 9ef30845..58973c9f 100644
--- a/telemetry-worker/test/auth.test.ts
+++ b/telemetry-worker/test/auth.test.ts
@@ -1121,7 +1121,7 @@ describe("accounts: GitHub and Google first, an email and a password always; cre
expect(html, tag).not.toContain("Create account with a passkey");
expect(html, tag).not.toContain("Sign in with a passkey");
// the fixture's Free has no dashboard (the real plans' line is checked in test/plans.test.ts)
- expect(html, tag).toContain("The runtime needs no account. Dashboards come with a paid plan.");
+ expect(html, tag).toContain("The runtime needs no account: set it up without one. Dashboards come with a paid plan.");
expect(html, tag).not.toMatch(/trial/i);
}
expect(await (await call(env, "GET", "/login", {})).text()).toContain('Forgot your password?');
diff --git a/telemetry-worker/test/bundle.test.ts b/telemetry-worker/test/bundle.test.ts
new file mode 100644
index 00000000..e6e51c59
--- /dev/null
+++ b/telemetry-worker/test/bundle.test.ts
@@ -0,0 +1,63 @@
+// The page scripts as Wrangler ships them. Wrangler bundles with esbuild's keepNames, which wraps named functions in a
+// __name() helper. installPrompt and openSourcePrompt reach the browser as their own source (fn.toString()), where
+// that helper does not exist, so a named inner function in them breaks the page (it did: /start/open-source's choices
+// threw "__name is not defined"). vitest's transform has no keepNames, so the other tests cannot see this.
+import { describe, expect, it } from "vitest";
+import { build } from "esbuild";
+import { rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { Window } from "happy-dom";
+
+/** src/pages.ts bundled as Wrangler does (esm, keepNames), imported. */
+async function bundledPages() {
+ const out = await build({
+ entryPoints: [fileURLToPath(new URL("../src/pages.ts", import.meta.url))],
+ bundle: true,
+ format: "esm",
+ platform: "neutral",
+ keepNames: true,
+ write: false,
+ logLevel: "silent",
+ });
+ const code = out.outputFiles[0]!.text;
+ const file = join(tmpdir(), `genclass-pages-${process.pid}-${code.length}.mjs`);
+ writeFileSync(file, code);
+ try {
+ return { code, mod: (await import(pathToFileURL(file).href)) as typeof import("../src/pages.js") };
+ } finally {
+ rmSync(file, { force: true });
+ }
+}
+
+describe("page scripts survive Wrangler's bundling (keepNames)", () => {
+ it("no inline script names the __name helper or holds a control character, and /start/open-source's choices still rewrite its prompt", async () => {
+ const { code, mod } = await bundledPages();
+ // keepNames is on: the bundle defines the helper, so this test exercises it
+ expect(code).toContain("__name");
+ const project = { name: "a", token: "gc_TestToken0123456789abc", created: "", lastEvent: null };
+ const pages: [string, string][] = [
+ ["/start/open-source", mod.openSourcePage("NONCE")],
+ ["/start", mod.startPage("NONCE")],
+ ["/dashboard", mod.dashboardPage("NONCE", project)],
+ ];
+ for (const [name, html] of pages) {
+ // a raw control character is an HTML parse error (the bundle prints escapes such as "\u0001" as the character)
+ expect(html, name).not.toMatch(/[\u0001-\u0008\u000B\u000C\u000E-\u001F\u007F]/);
+ const scripts = [...html.matchAll(/`);
expect(p).toContain("observe");
expect(p).toContain("Run the dev server");
@@ -884,9 +890,9 @@ describe("dashboard page: the Supabase Studio frame (src/studio.ts): header, rai
expect(p).toContain('import { connectAgent } from "@genclass/runtime/agent"; if (import.meta.env.DEV) connectAgent(rt);');
expect(p).toContain(`npx -y ${MCP_PKG} init`);
expect(p).toContain('import("@genclass/runtime/devtools").then((d) => d.mountDevtools(rt))');
- expect(p).toContain("genclass_status should then list the page");
+ expect(p).toContain("genclass_status lists a dev page once you load it with the MCP server running");
expect(p).toContain(`npx ${RUNTIME_PKG} init --token gc_TestToken0123456789abc --yes`);
- expect(installPrompt("gc_x", "https://c.example/v1/events")).toContain('GenClass.init({ token: "gc_x", telemetry: { endpoint: "https://c.example/v1/events" } });');
+ expect(installPrompt("gc_x", "https://c.example/v1/events")).toContain('GenClass.init({ token: "gc_x", autoState: true, telemetry: { endpoint: "https://c.example/v1/events" } });');
// a site with no build step gets everything from the prompt alone: the script tag, the agent link from the CDN on local hosts
expect(p).toContain("A site with no build step (plain HTML pages, no bundler) installs nothing");
expect(p).toContain(`import("${CDN_SCRIPT}/dist/agent.js")`);
@@ -899,7 +905,7 @@ describe("dashboard page: the Supabase Studio frame (src/studio.ts): header, rai
it("Connect prompt choices: guard (and eager), the badge on every host, the agent link with the Loop view, deploy", () => {
const t = "gc_TestToken0123456789abc";
const g = installPrompt(t, null, { guard: true, eager: true, badge: true, deploy: true, agentLink: "https://genclass.dev/dashboard/LINK" });
- expect(g).toContain(`const rt = GenClass.init({ token: "${t}", mode: "guard", aggressiveness: "eager", visual: { on: true, fix: true } });`);
+ expect(g).toContain(`const rt = GenClass.init({ token: "${t}", autoState: true, mode: "guard", aggressiveness: "eager", visual: { on: true, fix: true } });`);
expect(g).toContain('');
expect(g).toContain(``);
expect(g).toContain("3. Mode guard, as chosen on the dashboard");
@@ -1040,6 +1046,29 @@ describe("dashboard page: the Supabase Studio frame (src/studio.ts): header, rai
await s.close();
});
+ it("Connect choices a browser brings back with the page (back, reload; no change event) rewrite the prompt on pageshow", async () => {
+ const st = sampleStats("7d", NOW);
+ const r = await render(st, { account, id: "0123456789abcdef", firstEvent: "2026-10-12T09:00:00.000Z", runtimeReady: true });
+ const box = (k: string) => r.doc.querySelector(`#cchoices [data-choice="${k}"]`) as unknown as HTMLInputElement;
+ const copied = () => r.doc.getElementById("cpinstall")!.getAttribute("data-copy");
+ box("guard").checked = true;
+ box("badge").checked = true;
+ expect(copied()).toBe(installPrompt(st.token));
+ r.win.dispatchEvent(new r.win.Event("pageshow"));
+ expect(copied()).toBe(installPrompt(st.token, null, { guard: true, badge: true }));
+ expect(box("eager").disabled).toBe(false);
+ // a restored eager without guard, and a restored agent box with no link yet, are unticked; nothing is created
+ box("guard").checked = false;
+ box("eager").checked = true;
+ box("agent").checked = true;
+ r.win.dispatchEvent(new r.win.Event("pageshow"));
+ expect(box("eager").checked).toBe(false);
+ expect(box("agent").checked).toBe(false);
+ expect(copied()).toBe(installPrompt(st.token, null, { badge: true }));
+ expect(r.writes).toEqual([]);
+ await r.close();
+ });
+
it("Delete project in an account: Project Settings' danger zone asks once, then sends DELETE with the confirm; a private link has none", async () => {
const r = await render(sampleStats("7d", NOW), { account, id: "0123456789abcdef", firstEvent: "2026-10-12T09:00:00.000Z", runtimeReady: true });
expect([...r.doc.querySelectorAll(".sb-pm a")].some((a) => a.getAttribute("href") === "#delproj" && a.textContent === "Delete project")).toBe(true);
@@ -1090,3 +1119,212 @@ describe("dashboard page: the Supabase Studio frame (src/studio.ts): header, rai
}
});
});
+
+describe("/start/open-source: the open-source parts with no account, no token and nothing sent to genclass.dev", () => {
+ /** The page in happy-dom with its own script run; `fetch` records any request (there must be none). */
+ async function openSource(o: { refuse?: boolean; before?: (doc: Document) => void; pageshow?: boolean } = {}) {
+ const html = openSourcePage("NONCE");
+ const { body, script } = page(html);
+ const win = new Window({ url: "https://genclass.dev/start/open-source" });
+ const doc = win.document;
+ doc.body.innerHTML = body;
+ o.before?.(doc as unknown as Document);
+ const urls: string[] = [], copied: string[] = [];
+ // a clipboard that records what was copied, or one the browser refuses
+ const nav = { clipboard: { writeText: (t: string) => (o.refuse ? Promise.reject(new Error("denied")) : Promise.resolve(void copied.push(t))) } };
+ const run = new Function("window", "document", "location", "fetch", "navigator", script);
+ run(win, doc, win.location, async (u: string) => void urls.push(u), nav);
+ // a browser fires pageshow after load (and after it brings ticked boxes back); pageshow: false stops before it
+ if (o.pageshow !== false) win.dispatchEvent(new win.Event("pageshow"));
+ const box = (k: string) => doc.querySelector(`#choices [data-choice="${k}"]`) as unknown as HTMLInputElement;
+ const copy = () => doc.getElementById("cp")!.getAttribute("data-copy");
+ return { html, win, doc, urls, copied, box, copy, close: () => win.happyDOM.close() };
+ }
+
+ it("the default prompt: no token, telemetry off, observe, the agent link and the MCP server without a dashboard, the local scan", () => {
+ const p = openSourcePrompt();
+ expect(p).toContain("No GenClass account and no token: nothing is sent to genclass.dev.");
+ expect(p).toContain(`npm install ${RUNTIME_PKG}`);
+ expect(p).toContain("const rt = GenClass.init({ telemetry: false, autoState: true });");
+ // GenClass.init finds React, Redux and Zustand state only with the discover import first and autoState: true
+ expect(p).toContain('import "@genclass/runtime/discover"; // first');
+ expect(p.indexOf('import "@genclass/runtime/discover"')).toBeLessThan(p.indexOf('import { GenClass } from "@genclass/runtime"'));
+ // no data is sent, but the public files still come from jsDelivr unless the app serves them; the prompt says so
+ expect(p).toContain("so it sends no data anywhere; the browser still downloads its model and onnxruntime-web, public files, from cdn.jsdelivr.net");
+ expect(p).not.toContain("sends nothing anywhere");
+ expect(p).toContain(``);
+ expect(p).toContain(`npx ${RUNTIME_PKG} init --no-telemetry --yes does steps 1 and 2`);
+ expect(p).toContain("and adds step 3's dev-only overlay; the handle is then GenClass.runtime.)");
+ expect(p).toContain("Keep the default mode, observe");
+ expect(p).toContain('rt.atom("search", { query: "", results: [] })');
+ expect(p).toContain('import { connectAgent } from "@genclass/runtime/agent"; if (import.meta.env.DEV) connectAgent(rt);');
+ expect(p).toContain(`import("${CDN_SCRIPT}/dist/agent.js")`);
+ expect(p).toContain(`npx -y ${MCP_PKG} init writes .mcp.json`);
+ expect(p).toContain("leave GENCLASS_DASHBOARD and GENCLASS_REPORT unset");
+ // the MCP server's init prints its own setup lines (a bare GenClass.init()): the prompt keeps its own
+ expect(p).toContain("keep the setup from steps 2 and 5 instead");
+ expect(p).toContain(`npx ${RUNTIME_PKG} scan http://localhost:5173/ --local`);
+ expect(p).toContain("Do not use hosted: true: it sends the page URL to GenClass's hosted page check, which is not open source.");
+ // the scan needs Playwright: the agent asks before adding the dev dependency and downloading Chromium
+ expect(p).toContain("ask me before adding playwright as a dev dependency and downloading Chromium (about 600 MB): npm i -D playwright && npx playwright install chromium");
+ // the agent link's failed request before the MCP server runs is expected
+ expect(p).toContain("the page logs a failed request to 127.0.0.1:47357");
+ expect(p).toContain("In your next conversation, reload a dev page and genclass_status lists it.");
+ // discovery covers React state, Redux through its DevTools hooks and Zustand with devtools; the prompt says so
+ expect(p).toContain("Zustand stores made with the devtools middleware are found automatically");
+ expect(p).toContain("(about 13 MB, 18 MB on WebGPU devices, then cached)");
+ // nothing that needs an account, a token or the hosted dashboard
+ expect(p).not.toMatch(/token: "|data-token|--token|gc_[A-Za-z0-9]/);
+ expect(p).not.toContain("GENCLASS_DASHBOARD=");
+ expect(p).not.toContain("GENCLASS_REPORT=");
+ expect(p).not.toContain("genclass.dev/dashboard");
+ expect(p).not.toContain("consoleJson");
+ expect(p).not.toContain("fetch-model");
+ expect(p).not.toContain("Deploy it");
+ // the steps are numbered 1..n with none missing
+ const nums = [...p.matchAll(/^(\d+)\. /gm)].map((m) => Number(m[1]));
+ expect(nums).toEqual(nums.map((_, i) => i + 1));
+ expect(p).toMatch(/\n8\. Tell me what you changed, that the app sets no GenClass token and has telemetry off, what it downloads from outside this network/);
+ // only printable text (no step marker or other control character reaches the page)
+ expect(p).not.toMatch(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/);
+ });
+
+ it("the choices: guard and eager, findings in the app's own logs, the model served by the app, deploy", () => {
+ const p = openSourcePrompt({ guard: true, eager: true, logs: true, selfHost: true, deploy: true });
+ expect(p).toContain('import { consoleJson } from "@genclass/runtime/sinks/console-json";');
+ expect(p).toContain('const rt = GenClass.init({ telemetry: false, autoState: true, mode: "guard", aggressiveness: "eager", visual: { on: true, fix: true }, model: { baseUrl: "/genclass-model/", ortWasmPaths: "/genclass-model/ort/" }, sinks: [consoleJson()] });');
+ expect(p).toContain('');
+ expect(p).toContain(``);
+ expect(p).toContain(`npx ${RUNTIME_PKG} init --no-telemetry --mode guard --sink console-json --model-url /genclass-model/ --yes`);
+ // init writes no visual or aggressiveness option: the prompt says where they go
+ expect(p).toContain('add aggressiveness: "eager" and visual: { on: true, fix: true } to the option file it writes (data-visual="fix" on the tag it writes, and the GENCLASS_CONFIG line above; Astro gets a line instead: add visual=fix to its content, and the aggressiveness in a window.GENCLASS_CONFIG script before it); run the fetch-model command below before it.)');
+ expect(openSourcePrompt({ guard: true })).toContain('; add visual: { on: true, fix: true } to the option file it writes (data-visual="fix" on the tag it writes; Astro gets a line instead: add visual=fix to its content).)');
+ expect(p).toContain("Mode guard: when GenClass is sure");
+ expect(p).toContain("the webhook sink sends batched POSTs there instead");
+ expect(p).toContain('sinks: [webhook({ url: "" })], requests: { ignore: [""] }');
+ expect(p).toContain(`npx ${RUNTIME_PKG} fetch-model public/genclass-model`);
+ expect(p).toContain('run fetch-model public/genclass-model --variant q8 --ort wasm instead and add device: "wasm" to the model option (data-device="wasm" on the script tag');
+ // the model served by the app: no jsDelivr download for it to mention
+ expect(p).not.toContain("still downloads");
+ expect(p).toContain("Deploy it, as chosen");
+ expect(p).toMatch(/\n11\. Tell me what you changed and where it is deployed/);
+ // eager only with guard
+ expect(openSourcePrompt({ eager: true })).toBe(openSourcePrompt());
+ });
+
+ it("the page: ticking the choices rewrites the copied prompt with the server's own openSourcePrompt, and it sends nothing", async () => {
+ const r = await openSource();
+ expect(r.copy()).toBe(openSourcePrompt());
+ expect(r.box("eager").disabled).toBe(true);
+ r.box("guard").click();
+ expect(r.box("eager").disabled).toBe(false);
+ r.box("eager").click();
+ r.box("logs").click();
+ r.box("selfHost").click();
+ r.box("deploy").click();
+ const all = openSourcePrompt({ guard: true, eager: true, logs: true, selfHost: true, deploy: true });
+ expect(r.copy()).toBe(all);
+ expect((r.doc.getElementById("cpt") as unknown as HTMLTextAreaElement).value).toBe(all);
+ // unticking guard unticks eager
+ r.box("guard").click();
+ expect(r.box("eager").checked).toBe(false);
+ expect(r.copy()).toBe(openSourcePrompt({ logs: true, selfHost: true, deploy: true }));
+ (r.doc.getElementById("cp") as unknown as HTMLElement).click();
+ await new Promise((x) => setTimeout(x, 5));
+ expect(r.copied).toEqual([openSourcePrompt({ logs: true, selfHost: true, deploy: true })]);
+ // the commands copy too
+ const cmds = [...r.doc.querySelectorAll(".cmds [data-copy]")].map((b) => b.getAttribute("data-copy"));
+ expect(cmds).toEqual([
+ `npx ${RUNTIME_PKG} init --no-telemetry`,
+ `// in the app's entry, after the lines init added\nimport { GenClass } from "@genclass/runtime";\nimport { connectAgent } from "@genclass/runtime/agent";\nif (import.meta.env.DEV && GenClass.runtime) connectAgent(GenClass.runtime);`,
+ `// in the app's entry (Next.js: instrumentation-client.ts), after the lines init added\nimport { GenClass } from "@genclass/runtime";\nimport { connectAgent } from "@genclass/runtime/agent";\nif (process.env.NODE_ENV === "development" && GenClass.runtime) connectAgent(GenClass.runtime);`,
+ `npx -y ${MCP_PKG} init`,
+ `npx ${RUNTIME_PKG} scan http://localhost:5173/ --local`,
+ ]);
+ // the choices are a fieldset with a visible legend; the commands' titles are h3 under the section's h2
+ expect(r.doc.querySelector("fieldset#choices > legend")!.textContent).toContain("What the prompt sets up");
+ expect(r.doc.querySelectorAll(".cmds h3").length).toBe(5);
+ expect(r.doc.querySelectorAll(".cmds h2").length).toBe(1);
+ expect(r.urls).toEqual([]);
+ await r.close();
+ // no form, no request anywhere in the page, and the way back to the hosted options
+ expect(r.html).not.toContain("