quc v0 (4/4): tests, dev server, deploy, pipeline
15 tests (the run end to end against a fake GitHub tarball, safeword, the three phrases, veto revert, owner-only API, cross-site refusal, daily cap). dev.mjs takes POST bodies and a memory store. deploy.py creates the time-quc KV namespace and binds the Venice key as a Worker secret. quc.yml tests and deploys on main and skips the deploy until CLOUDFLARE_API_TOKEN is staged; ci.yml runs the quc tests on PRs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVg99kQMY2Tumhobuyg7Fm
This commit is contained in:
1 parent
e7c0ec1188
commit
84bf73bc83
8 files changed
+282
-61
No files matched your search
@@ -20,3 +20,4 @@ jobs:
|
||||
- run: python3 .github/time/test_time.py
|
||||
- run: python3 .github/time/test_seed.py
|
||||
- run: python3 .github/time/test_housekeeping.py
|
||||
- run: npm test --prefix quc
|
||||
@@ -0,0 +1,31 @@
|
||||
name: quc
|
||||
# Test and deploy quc (quc/, live at quc.<venture domain>) when it changes on main. Skips the deploy until the Cloudflare
|
||||
# token is staged as a repo secret (see .github/time/SECRETS.md); deploys from hb-5 work meanwhile.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["quc/**"]
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 6
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || 'dac3b11890319628eb5cd761445b63ee' }}
|
||||
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
|
||||
TIME_VENTURE_DOMAIN: ${{ vars.TIME_VENTURE_DOMAIN || 'yolopreneur.lol' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- run: npm test --prefix quc
|
||||
- name: deploy
|
||||
if: env.CLOUDFLARE_API_TOKEN != ''
|
||||
run: python3 quc/deploy.py
|
||||
- name: no token yet
|
||||
if: env.CLOUDFLARE_API_TOKEN == ''
|
||||
run: echo "CLOUDFLARE_API_TOKEN is not staged; tests passed, deploy skipped" >> "$GITHUB_STEP_SUMMARY"
|
||||
+27
-26
@@ -1,33 +1,34 @@
|
||||
# quc
|
||||
|
||||
App shell + API in one Cloudflare Worker (`src/worker.js`), live at https://quc.yolopreneur.lol/. No dependencies, no build step.
|
||||
Hand an agent your repo, sit in the chair, watch. Live at https://quc.yolopreneur.lol/ (spec: teahubxyz/time#220).
|
||||
|
||||
- `npm start` serves it on :8787; `npm test` runs the tests; `npm run deploy` (needs `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`) deploys and waits for `/api/health`.
|
||||
- `GET /api/health` → `{ok, rev}`. `GET /chair.svg` is the chair-in-the-corner motif (also inlined in the shell).
|
||||
One Cloudflare Worker, no dependencies, no build step. `npm start` serves it on :8787 (offline answers unless `VENICE_API_KEY`
|
||||
is set), `npm test` runs the tests, `npm run deploy` deploys (needs `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, and
|
||||
`VENICE_API_KEY` for real models) and waits for `/api/health`.
|
||||
|
||||
## Pipeline (needs a human: workers can't edit `.github/workflows`)
|
||||
## How a run works
|
||||
|
||||
Add `.github/workflows/quc.yml`:
|
||||
1. **Start**: a public GitHub URL and one line of task. One run at a time per visitor (a cookie), 5 runs a day each, 300 a
|
||||
day in all (`RUNS_PER_DAY`, `RUNS_PER_DAY_ALL`).
|
||||
2. **The cage**: quc downloads the repo's tarball from GitHub and keeps the readable text files (caps: 15 MB download, 2,000
|
||||
files, 40 KB a file, 4 files changed, 10 minutes). Nothing from the repo is executed, and nothing is pushed anywhere.
|
||||
3. **Steps**: the chair page calls `POST /api/runs/:id/step` again and again; each call does one thing (clone, plan, rewrite
|
||||
one file, wrap up), so every request stays short. A coding model (`CODER_MODEL`, default Qwen3 Coder via Venice) plans and
|
||||
rewrites whole files; Grok (`PERSONA_MODEL`, default `grok-4-3` via Venice) narrates. Diffs, commits and commentary are
|
||||
appended to the run's event log.
|
||||
4. **The chair**: the page shows the commentary and the diffs, read-only. Only the owner sees the safeword and the box.
|
||||
5. **Safeword**: ends the run at once and throws the edits away (the replay still shows what happened).
|
||||
6. **Magic words** (typed in the box, matched loosely; the persona drops hints, so keep them out of marketing copy):
|
||||
1. "may I say something": notes. Anything typed after this is passed to the agent as a note.
|
||||
2. "not that one": vetoes. Type a file's path and the agent reverts and skips it.
|
||||
3. "my repo, my rules": takes the wheel. The run ends as `unlocked` and the patch is yours.
|
||||
Anything else gets mocked.
|
||||
7. **Replay**: the same URL, for anyone: a scrubber over every event, plus the aftermath card and the patch download.
|
||||
|
||||
```yaml
|
||||
name: quc
|
||||
on:
|
||||
push: { branches: [main], paths: ["quc/**"] }
|
||||
workflow_dispatch:
|
||||
permissions: { contents: read }
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 6
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: npm test --prefix quc
|
||||
- run: python3 quc/deploy.py
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} # still "missing" in SECRETS.md
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
```
|
||||
Storage is a KV namespace (`time-quc`): `run:<id>`, `events:<id>`, `work:<id>` (repo text and edits), `active:<visitor>`,
|
||||
`cap:<day>:*`, `lock:<id>`. KV is eventually consistent and has no compare-and-set; a Durable Object per run is the upgrade
|
||||
when that matters.
|
||||
|
||||
and, in `ci.yml`, `- run: npm test --prefix quc` so PRs run the quc tests.
|
||||
## Pipeline
|
||||
|
||||
`.github/workflows/quc.yml` deploys on pushes to `quc/**` on main, and `ci.yml` runs `npm test --prefix quc` on PRs.
|
||||
+25
-9
@@ -1,8 +1,9 @@
|
||||
"""Deploy quc to quc.<TIME_VENTURE_DOMAIN> (default yolopreneur.lol) as the Worker `time-quc`, then wait for /api/health.
|
||||
|
||||
CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... python3 quc/deploy.py
|
||||
CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... VENICE_API_KEY=... python3 quc/deploy.py
|
||||
|
||||
Reuses .github/time/cloudflare.py. Idempotent: the script is replaced in place, the custom domain re-attached.
|
||||
Reuses .github/time/cloudflare.py. Idempotent: the KV namespace `time-quc` is created once and reused, the script is replaced
|
||||
in place, the custom domain re-attached. VENICE_API_KEY is bound as a Worker secret (without it the agent answers offline).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -19,7 +20,7 @@ sys.path.insert(0, str(HERE.parent / ".github" / "time"))
|
||||
import cloudflare as cf # noqa: E402
|
||||
|
||||
NAME = "time-quc"
|
||||
SRC = HERE / "src" / "worker.js"
|
||||
MODULES = ["worker.js", "page.js", "runs.js", "engine.js", "agent.js", "diff.js", "validate.js"]
|
||||
|
||||
|
||||
def healthy(url: str, tries: int = 18) -> bool:
|
||||
@@ -34,16 +35,31 @@ def healthy(url: str, tries: int = 18) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def kv_namespace(title: str) -> str:
|
||||
for ns in cf.cf("GET", f"{cf.account()}/storage/kv/namespaces?per_page=100") or []:
|
||||
if ns.get("title") == title:
|
||||
return ns["id"]
|
||||
return cf.cf("POST", f"{cf.account()}/storage/kv/namespaces", {"title": title})["id"]
|
||||
|
||||
|
||||
def deploy() -> dict:
|
||||
if not cf.enabled():
|
||||
sys.exit("CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are needed")
|
||||
os.environ.setdefault("TIME_VENTURE_DOMAIN", "yolopreneur.lol")
|
||||
code = SRC.read_bytes()
|
||||
rev = hashlib.sha256(code).hexdigest()[:12]
|
||||
meta = {"main_module": "worker.js", "compatibility_date": cf.COMPAT,
|
||||
"bindings": [{"type": "plain_text", "name": "REV", "text": rev}]}
|
||||
data, ctype = cf.multipart([("metadata", "application/json", json.dumps(meta).encode()),
|
||||
("worker.js", "application/javascript+module", code)])
|
||||
code = {m: (HERE / "src" / m).read_bytes() for m in MODULES}
|
||||
rev = hashlib.sha256(b"".join(code[m] for m in MODULES)).hexdigest()[:12]
|
||||
bindings = [{"type": "kv_namespace", "name": "KV", "namespace_id": kv_namespace(NAME)},
|
||||
{"type": "plain_text", "name": "REV", "text": rev}]
|
||||
for k in ("CODER_MODEL", "PERSONA_MODEL", "RUNS_PER_DAY", "RUNS_PER_DAY_ALL"):
|
||||
if os.environ.get(k):
|
||||
bindings.append({"type": "plain_text", "name": k, "text": os.environ[k]})
|
||||
if os.environ.get("VENICE_API_KEY"):
|
||||
bindings.append({"type": "secret_text", "name": "VENICE_API_KEY", "text": os.environ["VENICE_API_KEY"]})
|
||||
else:
|
||||
print("warning: no VENICE_API_KEY, so the agent gives offline answers", file=sys.stderr)
|
||||
meta = {"main_module": "worker.js", "compatibility_date": cf.COMPAT, "bindings": bindings}
|
||||
data, ctype = cf.multipart([("metadata", "application/json", json.dumps(meta).encode())]
|
||||
+ [(m, "application/javascript+module", code[m]) for m in MODULES])
|
||||
cf.cf("PUT", f"{cf.account()}/workers/scripts/{NAME}", data=data, ctype=ctype)
|
||||
return {"url": cf.attach(NAME, cf.host("quc")), "worker": NAME, "rev": rev}
|
||||
|
||||
|
||||
+9
-2
@@ -1,9 +1,16 @@
|
||||
// Run the Worker locally: `npm start` in quc/, then http://localhost:8787/
|
||||
// Without VENICE_API_KEY in the environment the agent gives canned offline answers; with it, it uses the real models.
|
||||
import { createServer } from "node:http";
|
||||
import worker from "./src/worker.js";
|
||||
import { memoryStore } from "./src/runs.js";
|
||||
|
||||
const env = { REV: "dev", KV: memoryStore(), VENICE_API_KEY: process.env.VENICE_API_KEY || "" };
|
||||
|
||||
createServer(async (q, s) => {
|
||||
const r = await worker.fetch(new Request(`http://${q.headers.host}${q.url}`, { method: q.method }), { REV: "dev" });
|
||||
const chunks = [];
|
||||
for await (const c of q) chunks.push(c);
|
||||
const body = chunks.length && q.method !== "GET" && q.method !== "HEAD" ? Buffer.concat(chunks) : undefined;
|
||||
const r = await worker.fetch(new Request(`http://${q.headers.host}${q.url}`, { method: q.method, headers: q.headers, body }), env);
|
||||
s.writeHead(r.status, Object.fromEntries(r.headers));
|
||||
s.end(Buffer.from(await r.arrayBuffer()));
|
||||
}).listen(process.env.PORT || 8787);
|
||||
}).listen(process.env.PORT || 8787, () => console.log(`quc on http://localhost:${process.env.PORT || 8787}/`));
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "quc",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "quc.yolopreneur.lol: app shell + API in one Cloudflare Worker. No dependencies, no build step.",
|
||||
"description": "quc.yolopreneur.lol: hand an agent your repo, sit in the chair, watch. One Cloudflare Worker, no dependencies, no build step.",
|
||||
"scripts": {
|
||||
"start": "node dev.mjs",
|
||||
"test": "node --test",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { test, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import worker, { validate } from "../src/worker.js";
|
||||
import { memoryStore, createRun, getRun, getEvents } from "../src/runs.js";
|
||||
import { step, say, safeword, patch } from "../src/engine.js";
|
||||
import { untar, parseRepo } from "../src/agent.js";
|
||||
import { unified } from "../src/diff.js";
|
||||
|
||||
// ---------- a fake GitHub: a gzipped tarball built in memory ----------
|
||||
|
||||
function tar(files) {
|
||||
const enc = new TextEncoder(), blocks = [];
|
||||
for (const [path, body] of Object.entries(files)) {
|
||||
const data = enc.encode(body), h = new Uint8Array(512);
|
||||
h.set(enc.encode("repo-abc123/" + path).subarray(0, 100), 0);
|
||||
h.set(enc.encode("0000644\0"), 100);
|
||||
h.set(enc.encode(data.length.toString(8).padStart(11, "0") + "\0"), 124);
|
||||
h[156] = 48;
|
||||
h.set(enc.encode("ustar\0"), 257);
|
||||
blocks.push(h, data, new Uint8Array((512 - (data.length % 512)) % 512));
|
||||
}
|
||||
blocks.push(new Uint8Array(1024));
|
||||
const out = new Uint8Array(blocks.reduce((n, b) => n + b.length, 0));
|
||||
let i = 0; for (const b of blocks) { out.set(b, i); i += b.length; }
|
||||
return out;
|
||||
}
|
||||
const gzip = async (bytes) => new Uint8Array(await new Response(new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer());
|
||||
const REPO = { "README.md": "# demo\n\nA tiny repo.\n", "src/index.js": "export const x = 1;\n", "logo.png": "\0\0binary" };
|
||||
let tgz;
|
||||
const github = async (url) => url.startsWith("https://codeload.github.com/") ? new Response(tgz, { headers: { "content-length": String(tgz.length) } }) : new Response("no", { status: 404 });
|
||||
beforeEach(async () => { tgz = await gzip(tar(REPO)); });
|
||||
|
||||
async function drive(store, id, n = 20) {
|
||||
for (let i = 0; i < n; i++) { const r = await step(store, id, {}, github); if (r.status === "ended") return r; }
|
||||
return getRun(store, id);
|
||||
}
|
||||
|
||||
// ---------- unit ----------
|
||||
|
||||
test("validate accepts a GitHub URL and a one-line task", () => {
|
||||
assert.deepEqual(validate("https://github.com/a/b", "make it better please"), {});
|
||||
assert.ok(validate("https://gitlab.com/a/b", "make it better please").repo);
|
||||
assert.ok(validate("https://github.com/a/b", "short").task);
|
||||
});
|
||||
|
||||
test("parseRepo and untar read GitHub's tarball shape", async () => {
|
||||
assert.deepEqual(parseRepo("https://github.com/o/n.git"), { owner: "o", name: "n" });
|
||||
const files = untar(tar({ "a.txt": "hi", "d/b.txt": "yo" })).map((f) => f.path);
|
||||
assert.deepEqual(files, ["a.txt", "d/b.txt"]);
|
||||
});
|
||||
|
||||
test("unified diff counts and marks lines", () => {
|
||||
const d = unified("f", "a\nb\nc\n", "a\nB\nc\nd\n");
|
||||
assert.equal(d.added, 2); assert.equal(d.removed, 1);
|
||||
assert.match(d.text, /^--- a\/f\n\+\+\+ b\/f\n@@ -1,3 \+1,4 @@/);
|
||||
assert.equal(unified("f", "same\n", "same\n").text, "");
|
||||
});
|
||||
|
||||
// ---------- the run ----------
|
||||
|
||||
test("a run clones, plans, rewrites and finishes, and the patch has the change", async () => {
|
||||
const store = memoryStore();
|
||||
const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" });
|
||||
const done = await drive(store, run.id);
|
||||
assert.equal(done.status, "ended"); assert.equal(done.outcome, "finished");
|
||||
const kinds = (await getEvents(store, run.id)).map((e) => e.kind);
|
||||
assert.ok(kinds.includes("diff") && kinds.includes("commit") && kinds.includes("commentary"));
|
||||
assert.equal(done.stats.files, 1);
|
||||
assert.match(await patch(store, run.id), /\+quc was here\./);
|
||||
assert.equal(await store.get("active:v1"), null, "the visitor's slot is free again");
|
||||
});
|
||||
|
||||
test("one active run per visitor", async () => {
|
||||
const store = memoryStore();
|
||||
await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" });
|
||||
await assert.rejects(createRun(store, { repo: "https://github.com/o/demo", task: "another one please", visitor: "v1" }), { code: "active" });
|
||||
});
|
||||
|
||||
test("the safeword stops the run and throws the work away", async () => {
|
||||
const store = memoryStore();
|
||||
const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" });
|
||||
await step(store, run.id, {}, github);
|
||||
const r = await safeword(store, run.id);
|
||||
assert.equal(r.outcome, "safeworded");
|
||||
assert.equal(await patch(store, run.id), "");
|
||||
assert.equal((await step(store, run.id, {}, github)).status, "ended", "a stopped run never moves again");
|
||||
});
|
||||
|
||||
test("talking from the chair: mocked until the phrases unlock notes, a veto, then the wheel", async () => {
|
||||
const store = memoryStore();
|
||||
const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" });
|
||||
await step(store, run.id, {}, github); // cloned
|
||||
await step(store, run.id, {}, github); // planned: README.md
|
||||
await step(store, run.id, {}, github); // README.md rewritten (and finished, one file planned)
|
||||
const r1 = await getRun(store, run.id);
|
||||
assert.equal(r1.status, "ended");
|
||||
|
||||
const store2 = memoryStore();
|
||||
const run2 = await createRun(store2, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v2" });
|
||||
await step(store2, run2.id, {}, github); await step(store2, run2.id, {}, github);
|
||||
assert.equal((await say(store2, run2.id, "hey stop")).kind, "commentary");
|
||||
assert.equal((await getRun(store2, run2.id)).level, 0);
|
||||
await say(store2, run2.id, "May I say something?");
|
||||
assert.equal((await getRun(store2, run2.id)).level, 1);
|
||||
await say(store2, run2.id, "please keep it short");
|
||||
assert.deepEqual((await getRun(store2, run2.id)).notes, ["please keep it short"]);
|
||||
await say(store2, run2.id, "not that one!");
|
||||
await say(store2, run2.id, "README.md");
|
||||
const vetoed = await getRun(store2, run2.id);
|
||||
assert.deepEqual(vetoed.vetoed, ["README.md"]); assert.deepEqual(vetoed.queue, []);
|
||||
await say(store2, run2.id, "My repo, my rules.");
|
||||
const end = await getRun(store2, run2.id);
|
||||
assert.equal(end.status, "ended"); assert.equal(end.outcome, "unlocked");
|
||||
});
|
||||
|
||||
test("a veto after the edit reverts it", async () => {
|
||||
const store = memoryStore();
|
||||
const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" });
|
||||
const r = await getRun(store, run.id);
|
||||
await step(store, run.id, {}, github); await step(store, run.id, {}, github);
|
||||
const live = await getRun(store, run.id); live.queue.push("src/index.js"); await store.put(`run:${run.id}`, JSON.stringify(live));
|
||||
await step(store, run.id, {}, github); // README.md edited, src/index.js still queued
|
||||
assert.match(await patch(store, run.id), /README/);
|
||||
await say(store, run.id, "may i say something"); await say(store, run.id, "not that one"); await say(store, run.id, "README.md");
|
||||
assert.equal(await patch(store, run.id), "");
|
||||
const ev = (await getEvents(store, run.id)).filter((e) => e.kind === "diff");
|
||||
assert.ok(ev.at(-1).data.reverted);
|
||||
assert.ok(r);
|
||||
});
|
||||
|
||||
// ---------- the Worker ----------
|
||||
|
||||
const call = (path, { method = "GET", body, cookie, origin } = {}) => worker.fetch(new Request("https://quc.test" + path, {
|
||||
method, body: body ? JSON.stringify(body) : undefined,
|
||||
headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}), ...(origin ? { origin } : {}) } }), env);
|
||||
let env;
|
||||
beforeEach(() => { env = { KV: memoryStore(), REV: "t" }; globalThis.fetch = github; });
|
||||
|
||||
test("health, page and chair render", async () => {
|
||||
assert.deepEqual(await (await call("/api/health")).json(), { ok: true, rev: "t", live: false });
|
||||
const page = await (await call("/")).text();
|
||||
assert.match(page, /Hand over your repo/); assert.match(page, /function validate/);
|
||||
assert.equal((await call("/run/abc123def0")).status, 200);
|
||||
assert.match(await (await call("/chair.svg")).text(), /<svg/);
|
||||
});
|
||||
|
||||
test("API: owner drives the run, others can only watch, the patch downloads", async () => {
|
||||
const res = await call("/api/runs", { method: "POST", body: { repo: "https://github.com/o/demo", task: "make the readme nicer" } });
|
||||
assert.equal(res.status, 201);
|
||||
const cookie = res.headers.get("set-cookie").split(";")[0];
|
||||
const { id } = await res.json();
|
||||
assert.equal((await call(`/api/runs/${id}/step`, { method: "POST", body: {} })).status, 403, "a stranger can't drive");
|
||||
assert.equal((await call(`/api/runs/${id}/step`, { method: "POST", body: {}, cookie, origin: "https://evil.test" })).status, 403, "no cross-site");
|
||||
for (let i = 0; i < 6; i++) await call(`/api/runs/${id}/step`, { method: "POST", body: {}, cookie });
|
||||
const view = await (await call(`/api/runs/${id}`)).json();
|
||||
assert.equal(view.owner, false); assert.equal(view.run.status, "ended"); assert.equal(view.run.visitor, undefined);
|
||||
assert.ok(view.events.length >= 4);
|
||||
const p = await call(`/api/runs/${id}/patch`);
|
||||
assert.match(await p.text(), /quc was here/);
|
||||
const again = await (await call(`/api/runs/${id}?after=${view.events.length}`)).json();
|
||||
assert.equal(again.events.length, 0);
|
||||
});
|
||||
|
||||
test("API: daily cap per visitor", async () => {
|
||||
env.RUNS_PER_DAY = 1;
|
||||
const first = await call("/api/runs", { method: "POST", body: { repo: "https://github.com/o/demo", task: "make the readme nicer" } });
|
||||
const cookie = first.headers.get("set-cookie").split(";")[0];
|
||||
const { id } = await first.json();
|
||||
await call(`/api/runs/${id}/safeword`, { method: "POST", body: {}, cookie });
|
||||
const second = await call("/api/runs", { method: "POST", body: { repo: "https://github.com/o/demo", task: "make the readme nicer" }, cookie });
|
||||
assert.equal(second.status, 429);
|
||||
});
|
||||
+15
-22
@@ -1,33 +1,32 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import worker from "../src/worker.js";
|
||||
import worker, { validate } from "../src/worker.js";
|
||||
|
||||
const get = (path, env, method = "GET") => worker.fetch(new Request("http://x" + path, { method }), env);
|
||||
const get = (path, env = {}, method = "GET") => worker.fetch(new Request("http://x" + path, { method }), env);
|
||||
|
||||
test("health reports ok and the revision", async () => {
|
||||
test("health reports ok, the revision, and whether real models are bound", async () => {
|
||||
const r = await get("/api/health", { REV: "abc" });
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(await r.json(), { ok: true, rev: "abc" });
|
||||
assert.deepEqual(await r.json(), { ok: true, rev: "abc", live: false });
|
||||
assert.equal((await (await get("/api/health", { VENICE_API_KEY: "k" })).json()).live, true);
|
||||
});
|
||||
|
||||
test("shell is responsive html that loads the motif", async () => {
|
||||
test("the page is responsive html, the motif is an svg, unknown paths 404", async () => {
|
||||
const r = await get("/");
|
||||
const html = await r.text();
|
||||
assert.match(r.headers.get("content-type"), /text\/html/);
|
||||
const html = await r.text();
|
||||
assert.match(html, /name="viewport" content="width=device-width/);
|
||||
assert.match(html, /chair\.svg/);
|
||||
});
|
||||
|
||||
test("motif is an svg, unknown paths 404, writes are refused", async () => {
|
||||
assert.match(html, /<form id="form"/);
|
||||
assert.match(html, /Replays are public/);
|
||||
const svg = await get("/chair.svg");
|
||||
assert.equal(svg.headers.get("content-type"), "image/svg+xml");
|
||||
assert.match(await svg.text(), /^<svg/);
|
||||
assert.equal((await get("/nope")).status, 404);
|
||||
assert.equal((await get("/api/health", {}, "POST")).status, 405);
|
||||
assert.equal((await get("/run/a/b")).status, 404);
|
||||
assert.equal((await get("/", {}, "POST")).status, 405);
|
||||
});
|
||||
|
||||
test("validate accepts a repo + task and rejects bad shapes", async () => {
|
||||
const { validate } = await import("../src/worker.js");
|
||||
test("validate accepts a repo + task and rejects bad shapes", () => {
|
||||
const task = "Fix the flaky date test";
|
||||
assert.deepEqual(validate("https://github.com/a/b", task), {});
|
||||
assert.deepEqual(validate("https://github.com/a/b.git/", task), {});
|
||||
@@ -37,13 +36,7 @@ test("validate accepts a repo + task and rejects bad shapes", async () => {
|
||||
assert.ok(validate("https://github.com/a/b", "x".repeat(201)).task);
|
||||
});
|
||||
|
||||
test("start form, public-replay notice and run page are served; the inlined validator parses", async () => {
|
||||
const html = await (await get("/")).text();
|
||||
assert.match(html, /<form id="form"/);
|
||||
assert.match(html, /Replays are public/);
|
||||
const run = await get("/run/abc123");
|
||||
assert.equal(run.status, 200);
|
||||
assert.match(await run.text(), /id="run-id"/);
|
||||
assert.equal((await get("/run/a/b")).status, 404);
|
||||
new Function(html.match(/<script>([\s\S]*)<\/script>/)[1]); // syntax check of the shipped script
|
||||
test("the shipped script parses", async () => {
|
||||
const html = await (await get("/run/abc123def0")).text();
|
||||
new Function(html.match(/<script type="module">([\s\S]*)<\/script>/)[1]);
|
||||
});
|
||||
Reference in new issue
Block a user