From f733b3ffb395c99de4d6a56315e482d9fc8ee868 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:26:10 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Let=20the=20smoke=20te?= =?UTF-8?q?sts=20start=20the=20site=20they=20talk=20to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The markdown smoke tests asserted against a site someone else had started. The workflow backgrounded `deno run -A main.tsx &`, polled with curl until the port answered, and handed the address over in SMOKE_URL. Nothing said so at the call site, `deno task smoke` on its own produced a wall of connection errors, and CI leaned on a background process surviving between steps. They start their own now. `serve` is split so the part that builds and starts the site is a resource, `useSite`, and `serve` is that plus the suspend that keeps the process alive. The suite takes that resource in a `beforeAll` from `@effectionx/bdd`, which holds it on the suite's scope: the site is up for every case and goes away with the suite. There is nothing to poll for and nothing to shut down by hand — `start()` resolves from `Deno.serve`'s `onListen`, and `revolution` closes the server in the resource's `finally`. One site for the suite rather than one per case is also forced: `initImageStore` can only initialize resvg's wasm once in a process. The options come from the Configliere command, so the defaults stay declared in one place, with four changed: port 0, so a dev server on 8000 is left alone, and clones, worktrees and tailwind output under build/smoke, because the site empties all three at boot and would otherwise pull them out from under a server already running. `deno task smoke` gains `-A`: booting the site runs git, shells out to tailwind and writes build directories. The workflow no longer passes a url, and the smoke step moves above the one that serves the site to show it no longer waits on it. That step stays, because staticalize still crawls it. --- .github/workflows/www.yaml | 30 +++-- www/deno.json | 3 +- www/main.tsx | 224 ++++++++++++++++--------------- www/tests/markdown-routes.ts | 249 ++++++++++++++++++++++------------- 4 files changed, 291 insertions(+), 215 deletions(-) diff --git a/.github/workflows/www.yaml b/.github/workflows/www.yaml index 13a7e7c4c..f054f074c 100644 --- a/.github/workflows/www.yaml +++ b/.github/workflows/www.yaml @@ -55,6 +55,14 @@ jobs: echo "url=$URL" >> "$GITHUB_OUTPUT" echo "Preview will be served from $URL" >> "$GITHUB_STEP_SUMMARY" + - name: Smoke test + run: deno task smoke + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JSR_API: ${{ secrets.JSR_API }} + timeout-minutes: 5 + working-directory: ./www + - name: Serve Website run: | deno run -A main.tsx & @@ -68,13 +76,6 @@ jobs: timeout-minutes: 5 working-directory: ./www - - name: Smoke test - run: deno task smoke - env: - SMOKE_URL: http://127.0.0.1:8000 - timeout-minutes: 5 - working-directory: ./www - - name: Download Staticalize run: | wget https://github.com/thefrontside/staticalize/releases/download/v0.3.1/staticalize-linux.tar.gz \ @@ -165,6 +166,14 @@ jobs: with: deno-version: v2.9.1 + - name: Smoke test + run: deno task smoke + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + JSR_API: ${{ secrets.JSR_API }} + timeout-minutes: 5 + working-directory: ./www + - name: Serve Website run: | deno run -A main.tsx & @@ -178,13 +187,6 @@ jobs: timeout-minutes: 5 working-directory: ./www - - name: Smoke test - run: deno task smoke - env: - SMOKE_URL: http://127.0.0.1:8000 - timeout-minutes: 5 - working-directory: ./www - - name: Download Staticalize run: | wget https://github.com/thefrontside/staticalize/releases/download/v0.3.1/staticalize-linux.tar.gz \ diff --git a/www/deno.json b/www/deno.json index a3556e421..776677b75 100644 --- a/www/deno.json +++ b/www/deno.json @@ -3,7 +3,7 @@ "dev": "deno run -A @effectionx/watch deno run -A main.tsx", "staticalize": "deno run -A npm:staticalize@0.3.1 --site http://localhost:8000 --output=built --base=http://localhost:8000", "test": "deno test --allow-run --allow-write --allow-read --allow-env", - "smoke": "deno test --allow-net --allow-env tests/markdown-routes.ts" + "smoke": "deno test -A tests/markdown-routes.ts" }, "lint": { "exclude": [ @@ -51,6 +51,7 @@ "@types/hast": "npm:@types/hast@3.0.4", "effection": "npm:effection@^3.6.1", "@effectionx/context-api": "npm:@effectionx/context-api@^0.6.0", + "@effectionx/bdd": "jsr:@effectionx/bdd@^0.3.1", "@effectionx/deno-deploy": "jsr:@effectionx/deno-deploy@^0.3.1", "@effectionx/process": "jsr:@effectionx/process@^0.6.2", "hast": "npm:hast@^1.0.0", diff --git a/www/main.tsx b/www/main.tsx index 45004e652..38d092d8b 100644 --- a/www/main.tsx +++ b/www/main.tsx @@ -1,4 +1,4 @@ -import { main, suspend } from "effection"; +import { main, type Operation, resource, suspend } from "effection"; import { parse, printErrors, printHelp } from "@frontside/configliere"; import { createRevolution, ServerInfo } from "revolution"; @@ -65,113 +65,127 @@ if (import.meta.main) { } } -function* serve(options: Options) { - yield* verboseLogging(options.verbose); - - let { current, series } = yield* useConfig(); - - // Get stable series (no prereleases) for guides - let stableSeries = series.filter((s) => !s.includePrerelease); - - yield* initClones(options.clonesDir, { - checkouts: localCheckouts(options.effectionxDir), - }); - yield* initWorktrees(options.worktreesDir); - yield* initGuides({ - current, - worktrees: stableSeries - .filter((s) => s.name !== current) - .map((s) => s.name), - }); - - yield* initBlog(); - yield* initFonts(); - yield* initImageStore(); - - yield* initJSRClient(options.jsrApi); - yield* initFetch(); - - // configures Octokit client - yield* initOctokitContext(options.githubToken); - - let revolution = createRevolution({ - app: [ - route("/", indexRoute()), - route("/search", searchRoute()), - route("/docs", redirectIndexRoute(firstPage(current))), - route("/docs/:id", redirectDocsRoute(current)), - // Guides only for stable series (no prereleases) - ...stableSeries.map((s) => - route(`/guides/${s.name}`, redirectIndexRoute(firstPage(s.name))) - ), - // before the page route, so that `.md` is a suffix and not a guide id - route("/guides/:series/:id.md", guidesMarkdownRoute()), - route("/guides/:series/:id", guidesRoute({ search: true })), - route("/contrib", xIndexRedirect()), - route("/contrib/:workspacePath", xPackageRedirect()), - route("/x", xIndexRoute({ search: true })), - // before the page route, so that `.md` is a suffix and not a package - route("/x/:workspacePath.md", xPackageMarkdownRoute()), - route("/x/:workspacePath", xPackageRoute({ search: true })), - route("/api", apiIndexRoute({ search: true })), - // before the page routes, so that `.md` is a suffix and not a symbol - route("/api.md", apiIndexMarkdownRoute()), - ...series.map((s) => - route(`/api/${s.name}/:symbol.md`, apiSymbolMarkdownRoute(s.name)) - ), - ...series.map((s) => - route( - `/api/${s.name}/experimental/:symbol.md`, - apiSymbolMarkdownRoute(s.name, { entrypoint: "./experimental" }), - ) - ), - // API docs for all series including prereleases - ...series.map((s) => - route( - `/api/${s.name}/:symbol`, - apiReferenceRoute(s.name, { search: true }), - ) - ), - // Experimental API docs (namespaced; empty for series without the - // `./experimental` entrypoint) - ...series.map((s) => +/** + * The running site, as a resource: everything it needs is set up before it is + * provided, and the server shuts down when the enclosing scope exits. + * + * `main.tsx` keeps it running until the process ends; a test holds it for the + * length of a suite and lets its scope close. + */ +export function useSite(options: Options): Operation { + return resource(function* (provide) { + yield* verboseLogging(options.verbose); + + let { current, series } = yield* useConfig(); + + // Get stable series (no prereleases) for guides + let stableSeries = series.filter((s) => !s.includePrerelease); + + yield* initClones(options.clonesDir, { + checkouts: localCheckouts(options.effectionxDir), + }); + yield* initWorktrees(options.worktreesDir); + yield* initGuides({ + current, + worktrees: stableSeries + .filter((s) => s.name !== current) + .map((s) => s.name), + }); + + yield* initBlog(); + yield* initFonts(); + yield* initImageStore(); + + yield* initJSRClient(options.jsrApi); + yield* initFetch(); + + // configures Octokit client + yield* initOctokitContext(options.githubToken); + + let revolution = createRevolution({ + app: [ + route("/", indexRoute()), + route("/search", searchRoute()), + route("/docs", redirectIndexRoute(firstPage(current))), + route("/docs/:id", redirectDocsRoute(current)), + // Guides only for stable series (no prereleases) + ...stableSeries.map((s) => + route(`/guides/${s.name}`, redirectIndexRoute(firstPage(s.name))) + ), + // before the page route, so that `.md` is a suffix and not a guide id + route("/guides/:series/:id.md", guidesMarkdownRoute()), + route("/guides/:series/:id", guidesRoute({ search: true })), + route("/contrib", xIndexRedirect()), + route("/contrib/:workspacePath", xPackageRedirect()), + route("/x", xIndexRoute({ search: true })), + // before the page route, so that `.md` is a suffix and not a package + route("/x/:workspacePath.md", xPackageMarkdownRoute()), + route("/x/:workspacePath", xPackageRoute({ search: true })), + route("/api", apiIndexRoute({ search: true })), + // before the page routes, so that `.md` is a suffix and not a symbol + route("/api.md", apiIndexMarkdownRoute()), + ...series.map((s) => + route(`/api/${s.name}/:symbol.md`, apiSymbolMarkdownRoute(s.name)) + ), + ...series.map((s) => + route( + `/api/${s.name}/experimental/:symbol.md`, + apiSymbolMarkdownRoute(s.name, { entrypoint: "./experimental" }), + ) + ), + // API docs for all series including prereleases + ...series.map((s) => + route( + `/api/${s.name}/:symbol`, + apiReferenceRoute(s.name, { search: true }), + ) + ), + // Experimental API docs (namespaced; empty for series without the + // `./experimental` entrypoint) + ...series.map((s) => + route( + `/api/${s.name}/experimental/:symbol`, + apiReferenceRoute(s.name, { + search: true, + entrypoint: "./experimental", + }), + ) + ), + route("/blog", blogIndexRoute({ search: true })), + route("/blog/feed.xml", blogFeedRoute()), + route("/llms.txt", llmsTxtRoute()), + route("/AGENTS.md", agentsMdRoute()), + route("/blog/tags/:tag", blogTagRoute({ search: true })), + route("/blog/:id", blogPostRoute({ search: true })), + route("/blog/:id/:name.png", blogImageRoute()), + route("/blog{/*path}", assetsRoute("blog")), route( - `/api/${s.name}/experimental/:symbol`, - apiReferenceRoute(s.name, { - search: true, - entrypoint: "./experimental", - }), - ) - ), - route("/blog", blogIndexRoute({ search: true })), - route("/blog/feed.xml", blogFeedRoute()), - route("/llms.txt", llmsTxtRoute()), - route("/AGENTS.md", agentsMdRoute()), - route("/blog/tags/:tag", blogTagRoute({ search: true })), - route("/blog/:id", blogPostRoute({ search: true })), - route("/blog/:id/:name.png", blogImageRoute()), - route("/blog{/*path}", assetsRoute("blog")), - route( - "/pagefind{/*path}", - pagefindRoute({ pagefindDir: options.pagefindDir }), - ), - route("/assets/*path", assetsRoute("assets")), - ], - plugins: [ - yield* tailwindPlugin({ - input: options.tailwindInput, - outdir: options.tailwindOutdir, - }), - inlineSvgPlugin({ - basedir: new URL(".", import.meta.url).pathname, - }), - currentRequestPlugin(), - yield* etagPlugin({ deploymentId: options.denoDeploymentId }), - sitemapPlugin(), - ], + "/pagefind{/*path}", + pagefindRoute({ pagefindDir: options.pagefindDir }), + ), + route("/assets/*path", assetsRoute("assets")), + ], + plugins: [ + yield* tailwindPlugin({ + input: options.tailwindInput, + outdir: options.tailwindOutdir, + }), + inlineSvgPlugin({ + basedir: new URL(".", import.meta.url).pathname, + }), + currentRequestPlugin(), + yield* etagPlugin({ deploymentId: options.denoDeploymentId }), + sitemapPlugin(), + ], + }); + + yield* provide(yield* revolution.start({ port: options.port })); }); +} + +function* serve(options: Options) { + let server = yield* useSite(options); - let server = yield* revolution.start({ port: options.port }); console.log(`www -> ${urlFromServer(server)}`); yield* suspend(); diff --git a/www/tests/markdown-routes.ts b/www/tests/markdown-routes.ts index baf7a943f..52e12a7b3 100644 --- a/www/tests/markdown-routes.ts +++ b/www/tests/markdown-routes.ts @@ -1,28 +1,46 @@ import { assertEquals, assertMatch, assertStringIncludes } from "@std/assert"; +import { beforeAll, describe, it } from "@effectionx/bdd"; +import { until } from "effection"; +import { parse } from "@frontside/configliere"; + +import { useSite } from "../main.tsx"; +import { www } from "../cli.ts"; /** * Smoke tests for the markdown the site serves to agents. * - * These run against a site that is already serving, because the documents come - * from checkouts and generated API docs rather than from fixtures: - * - * deno task dev # in one terminal - * deno task smoke # in another + * The suite starts the site itself and shuts it down afterwards, so it needs + * nothing running first: * - * `SMOKE_URL` points them at a different site, which is how the workflow runs - * them against the server it starts before a static build. + * deno task smoke * - * The file is deliberately not named `*.test.ts`: `deno task test` would - * otherwise pick it up and fail, since there is no server to talk to. + * It is slow — a boot builds guide worktrees and compiles css, and the first + * document that mentions a package clones the repository it came from — and it + * needs the network, so it is deliberately not named `*.test.ts` and stays out + * of `deno task test`. */ -const SITE = Deno.env.get("SMOKE_URL") ?? "http://localhost:8000"; - -async function get(path: string): Promise<[Response, string]> { - // `SITE` is where the documents are fetched from; the urls inside them - // belong to whatever the site is configured to advertise, which in a preview - // build is not the address the tests are talking to - let response = await fetch(new URL(path, SITE)); - return [response, await response.text()]; + +function options() { + let parsed = parse(www, { + // the declared defaults, plus GITHUB_TOKEN and JSR_API when the + // environment has them, so the crawl is not rate limited + argv: [], + envs: [{ name: "environment", value: Deno.env.toObject() }], + }); + + if (!parsed.ok || parsed.method !== "execute") { + throw new Error("could not read the site's default options"); + } + + return { + ...parsed.model, + // let the os pick, so a dev server on 8000 is left alone + port: 0, + // and keep off the directories that server rebuilds from scratch on boot + clonesDir: "build/smoke/clones", + worktreesDir: "build/smoke/worktrees", + tailwindOutdir: "build/smoke/tailwind", + }; } function markdown(response: Response) { @@ -33,88 +51,129 @@ function markdown(response: Response) { ); } -Deno.test("/AGENTS.md serves the behavioral contract", async () => { - let [response, body] = await get("/AGENTS.md"); - - markdown(response); - assertStringIncludes(body, "## Core invariants (do not violate)"); - assertStringIncludes(body, "## `ensure()`"); - // the repository's own rules stay in the repository - assertEquals(body.includes("## Pre-commit workflow"), false); - // and its links lead to the site rather than to github - assertMatch(body, /consult the API reference:\nhttps?:\/\/\S+\/api\//); - assertEquals(body.includes("raw.githubusercontent.com"), false); -}); - -Deno.test("/llms.txt leads to markdown, not to github", async () => { - let [response, body] = await get("/llms.txt"); - - // the urls themselves belong to whichever site is serving, so match shape - assertEquals(response.status, 200); - assertMatch(body, /^\[AGENTS\.md\]: https?:\/\/\S+\/AGENTS\.md$/m); - assertMatch(body, /^\[API\]: https?:\/\/\S+\/api\.md$/m); - assertMatch( - body, - /^\[Operations\]: https?:\/\/\S+\/guides\/v4\/operations\.md$/m, - ); - assertMatch( - body, - /^- \[@effectionx\/task-buffer\]\(https?:\/\/\S+\/x\/task-buffer\.md\)/m, - ); - assertEquals(body.includes("github.com"), false); -}); - -Deno.test("/guides/:series/:id.md serves a guide's source", async () => { - let [response, body] = await get("/guides/v4/operations.md"); - - markdown(response); - assertStringIncludes(body, "## Stateless"); -}); - -Deno.test("/x/:package.md serves a readme that says how to install it", async () => { - let [response, body] = await get("/x/task-buffer.md"); - - markdown(response); - assertStringIncludes(body, "# Task Buffer"); - // exactly once: the readmes that already say it are left alone - assertEquals(body.split("npm install @effectionx/task-buffer").length - 1, 1); -}); - -Deno.test("/api.md indexes the symbols, /api/:series/:symbol.md documents one", async () => { - let [index, list] = await get("/api.md"); - - markdown(index); - assertStringIncludes(list, "# API Reference"); - assertMatch(list, /^- \[main\]\(https?:\/\/\S+\/api\/v4\/main\.md\)$/m); +describe("the markdown an agent reads", () => { + /** + * Where this suite's own site is listening. `beforeAll` holds the resource + * on the suite's scope, so the server is up for every case below and goes + * away with the suite. + */ + let site: URL; + + beforeAll(function* () { + let server = yield* useSite(options()); + + site = new URL( + "/", + `http://${ + server.hostname === "0.0.0.0" ? "localhost" : server.hostname + }:${server.port}`, + ); + }); + + // `site` is where the documents are fetched from; the urls inside them + // belong to whatever the site is configured to advertise, which is not + // necessarily the address these cases are talking to + function get(path: string) { + return until( + fetch(new URL(path, site)).then( + async (response): Promise<[Response, string]> => [ + response, + await response.text(), + ], + ), + ); + } - let [symbol, page] = await get("/api/v4/main.md"); + it("/AGENTS.md serves the behavioral contract", function* () { + let [response, body] = yield* get("/AGENTS.md"); + + markdown(response); + assertStringIncludes(body, "## Core invariants (do not violate)"); + assertStringIncludes(body, "## `ensure()`"); + // the repository's own rules stay in the repository + assertEquals(body.includes("## Pre-commit workflow"), false); + // and its links lead to the site rather than to github + assertMatch(body, /consult the API reference:\nhttps?:\/\/\S+\/api\//); + assertEquals(body.includes("raw.githubusercontent.com"), false); + }); + + it("/llms.txt leads to markdown, not to github", function* () { + let [response, body] = yield* get("/llms.txt"); + + // the urls themselves belong to whichever site is serving, so match shape + assertEquals(response.status, 200); + assertMatch(body, /^\[AGENTS\.md\]: https?:\/\/\S+\/AGENTS\.md$/m); + assertMatch(body, /^\[API\]: https?:\/\/\S+\/api\.md$/m); + assertMatch( + body, + /^\[Operations\]: https?:\/\/\S+\/guides\/v4\/operations\.md$/m, + ); + assertMatch( + body, + /^- \[@effectionx\/task-buffer\]\(https?:\/\/\S+\/x\/task-buffer\.md\)/m, + ); + assertEquals(body.includes("github.com"), false); + }); - markdown(symbol); - assertStringIncludes(page, "# main"); - assertMatch(page, /```ts\n.*function main/); -}); + it("/guides/:series/:id.md serves a guide's source", function* () { + let [response, body] = yield* get("/guides/v4/operations.md"); -Deno.test("an unknown symbol is not found rather than a crash", async () => { - let [response] = await get("/api/v4/does-not-exist.md"); + markdown(response); + assertStringIncludes(body, "## Stateless"); + }); - assertEquals(response.status, 404); -}); + it("/x/:package.md serves a readme that says how to install it", function* () { + let [response, body] = yield* get("/x/task-buffer.md"); -Deno.test("the pages these documents come from still render", async () => { - for ( - let path of [ - "/api", - "/api/v4/main", - "/guides/v4/operations", - "/x/task-buffer", - ] - ) { - let [response] = await get(path); - - assertEquals(response.status, 200, `${path} responded ${response.status}`); - assertStringIncludes( - response.headers.get("Content-Type") ?? "", - "text/html", + markdown(response); + assertStringIncludes(body, "# Task Buffer"); + // exactly once: the readmes that already say it are left alone + assertEquals( + body.split("npm install @effectionx/task-buffer").length - 1, + 1, ); - } + }); + + it("/api.md indexes the symbols, /api/:series/:symbol.md documents one", function* () { + let [index, list] = yield* get("/api.md"); + + markdown(index); + assertStringIncludes(list, "# API Reference"); + assertMatch(list, /^- \[main\]\(https?:\/\/\S+\/api\/v4\/main\.md\)$/m); + + let [symbol, page] = yield* get("/api/v4/main.md"); + + markdown(symbol); + assertStringIncludes(page, "# main"); + assertMatch(page, /```ts\n.*function main/); + }); + + it("an unknown symbol is not found rather than a crash", function* () { + let [response] = yield* get("/api/v4/does-not-exist.md"); + + assertEquals(response.status, 404); + }); + + it("the pages these documents come from still render", function* () { + for ( + let path of [ + "/api", + "/api/v4/main", + "/guides/v4/operations", + "/x/task-buffer", + ] + ) { + let [response] = yield* get(path); + + assertEquals( + response.status, + 200, + `${path} responded ${response.status}`, + ); + assertStringIncludes( + response.headers.get("Content-Type") ?? "", + "text/html", + ); + } + }); }); From 282760733bf3f9bedba576ea0790a417a69bbc91 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:04:35 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=94=A5=20Replace=20the=20local=20bdd?= =?UTF-8?q?=20harness=20with=20@effectionx/bdd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `www/testing.ts` and `www/testing/adapter.ts` were a copy of `@effectionx/bdd` and the test adapter underneath it, and had not been touched since a lint sweep in January. The published package does the same thing — `describe` and `it` with operation bodies, a scope per suite — and keeps up with effection. The two files that used it import `describe` and `it` with the same signatures, so only the specifier changes. `@std/testing` leaves the import map with the harness that was its only consumer; the package resolves its own copy. `beforeAll` is the reason to move: the local fork never had it, and holding a resource for a whole suite is what the smoke tests need. --- www/deno.json | 1 - www/hooks/use-markdown.test.ts | 2 +- www/lib/command-parser.test.ts | 2 +- www/testing.ts | 62 --------------- www/testing/adapter.ts | 134 --------------------------------- 5 files changed, 2 insertions(+), 199 deletions(-) delete mode 100644 www/testing.ts delete mode 100644 www/testing/adapter.ts diff --git a/www/deno.json b/www/deno.json index 776677b75..bb949812f 100644 --- a/www/deno.json +++ b/www/deno.json @@ -88,7 +88,6 @@ "@std/fs": "jsr:@std/fs@1", "@std/path": "jsr:@std/path@1", "@std/yaml": "jsr:@std/yaml@1", - "@std/testing": "jsr:@std/testing@1", "md5": "npm:md5@^2.3.0", "expect": "jsr:@std/expect@^1", "arktype": "npm:arktype@^2", diff --git a/www/hooks/use-markdown.test.ts b/www/hooks/use-markdown.test.ts index 0a83a6cdf..8bc1add72 100644 --- a/www/hooks/use-markdown.test.ts +++ b/www/hooks/use-markdown.test.ts @@ -1,4 +1,4 @@ -import { describe, it } from "../testing.ts"; +import { describe, it } from "@effectionx/bdd"; import { expect } from "expect"; import { createJsDocSanitizer, escapeMdxSyntax } from "./use-markdown.tsx"; diff --git a/www/lib/command-parser.test.ts b/www/lib/command-parser.test.ts index 74c09c3e3..5a4e13883 100644 --- a/www/lib/command-parser.test.ts +++ b/www/lib/command-parser.test.ts @@ -1,4 +1,4 @@ -import { describe, it } from "../testing.ts"; +import { describe, it } from "@effectionx/bdd"; import { expect } from "expect"; import { parseCommand, splitCommand } from "./command-parser.ts"; diff --git a/www/testing.ts b/www/testing.ts deleted file mode 100644 index d7188e256..000000000 --- a/www/testing.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Operation } from "effection"; -import { - afterAll as $afterAll, - describe as $describe, - it as $it, -} from "@std/testing/bdd"; -import { createTestAdapter, type TestAdapter } from "./testing/adapter.ts"; - -let current: TestAdapter | undefined; - -export function describe(name: string, body: () => void) { - let isTop = !current; - let original = current; - try { - let child = current = createTestAdapter({ name, parent: original }); - if (isTop) { - // - } - - $describe(name, () => { - $afterAll(() => child.destroy()); - body(); - }); - } finally { - current = original; - } -} - -describe.skip = $describe.skip; -describe.only = $describe.only; - -export function beforeEach(body: () => Operation) { - current?.addSetup(body); -} - -export function it(desc: string, body?: () => Operation): void { - let adapter = current!; - if (!body) { - return $it.skip(desc, () => {}); - } - $it(desc, async () => { - let result = await adapter.runTest(body); - if (!result.ok) { - throw result.error; - } - }); -} - -it.skip = (...args: Parameters): ReturnType => { - let [desc] = args; - $it.skip(desc, () => {}); -}; - -it.only = (desc: string, body: () => Operation): void => { - let adapter = current!; - $it.only(desc, async () => { - let result = await adapter.runTest(body); - if (!result.ok) { - throw result.error; - } - }); -}; diff --git a/www/testing/adapter.ts b/www/testing/adapter.ts deleted file mode 100644 index d5a58f952..000000000 --- a/www/testing/adapter.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { Future, Operation, Result, Scope } from "effection"; -import { createScope, Err, Ok } from "effection"; - -export interface TestOperation { - (): Operation; -} - -export interface TestAdapter { - /** - * The parent of this adapter. All of the setup from this adapter will be - * run in addition to the setup of this adapter during `runTest()` - */ - readonly parent?: TestAdapter; - - /** - * The name of this adapter which is mostly useful for debugging purposes - */ - readonly name: string; - - /** - * A qualified name that contains not only the name of this adapter, but of all its - * ancestors. E.g. `All Tests > File System > write` - */ - readonly fullname: string; - - /** - * Every test adapter has its own Effection `Scope` which holds the resources necessary - * to run this test. - */ - readonly scope: Scope; - - /** - * A list of this test adapter and every adapter that it descends from. - */ - readonly lineage: Array; - - /** - * The setup operations that will be run by this test adapter. It only includes those - * setups that are associated with this adapter, not those of its ancestors. - */ - readonly setups: TestOperation[]; - - /** - * Add a setup operation to every test that is part of this adapter. In BDD integrations, - * this is usually called by `beforEach()` - */ - addSetup(op: TestOperation): void; - - /** - * Actually run a test. This evaluates all setup operations, and then after those have completed - * it runs the body of the test itself. - */ - runTest(body: TestOperation): Future>; - - /** - * Teardown this test adapter and all of the task and resources that are running inside it. - * This basically destroys the Effection `Scope` associated with this adapter. - */ - destroy(): Future; -} - -export interface TestAdapterOptions { - /** - * The name of this test adapter which is handy for debugging. - * Usually, you'll give this the same name as the current test - * context. For example, when integrating with BDD, this would be - * the same as - */ - name?: string; - /** - * The parent test adapter. All of the setup from this adapter will be - * run in addition to the setup of this adapter during `runTest()` - */ - parent?: TestAdapter; -} - -const anonymousNames: Iterator = (function* () { - let count = 1; - while (true) { - yield `anonymous test adapter ${count++}`; - } -})(); - -/** - * Create a new test adapter with the given options. - */ -export function createTestAdapter( - options: TestAdapterOptions = {}, -): TestAdapter { - let setups: TestOperation[] = []; - let { parent, name = anonymousNames.next().value } = options; - - let [scope, destroy] = createScope(parent?.scope); - - let adapter: TestAdapter = { - parent, - name, - scope, - setups, - get lineage() { - let lineage = [adapter]; - for (let current = parent; current; current = current.parent) { - lineage.unshift(current); - } - return lineage; - }, - get fullname() { - return adapter.lineage.map((adapter) => adapter.name).join(" > "); - }, - addSetup(op) { - setups.push(op); - }, - runTest(op) { - return scope.run(function* () { - let allSetups = adapter.lineage.reduce( - (all, adapter) => all.concat(adapter.setups), - [] as TestOperation[], - ); - try { - for (let setup of allSetups) { - yield* setup(); - } - yield* op(); - return Ok(void 0); - } catch (error) { - return Err(error as Error); - } - }); - }, - destroy, - }; - - return adapter; -} From 6fac970d6b762f982962c4f63eb71559dcc037b5 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:16:39 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Fetch=20the=20document?= =?UTF-8?q?s=20with=20@effectionx/fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper the cases call wrapped a promise chain in `until` to turn a response and its body into one operation. `@effectionx/fetch` is that already — the request is an operation and so is reading the body: function* get(path: string) { let response = yield* fetch(new URL(path, site)); return [response, yield* response.text()] as const; } No promise to adapt, and nothing in the file is `async` any more. --- www/deno.json | 1 + www/tests/markdown-routes.ts | 17 ++++++----------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/www/deno.json b/www/deno.json index bb949812f..76af5cb13 100644 --- a/www/deno.json +++ b/www/deno.json @@ -52,6 +52,7 @@ "effection": "npm:effection@^3.6.1", "@effectionx/context-api": "npm:@effectionx/context-api@^0.6.0", "@effectionx/bdd": "jsr:@effectionx/bdd@^0.3.1", + "@effectionx/fetch": "npm:@effectionx/fetch@^0.2.1", "@effectionx/deno-deploy": "jsr:@effectionx/deno-deploy@^0.3.1", "@effectionx/process": "jsr:@effectionx/process@^0.6.2", "hast": "npm:hast@^1.0.0", diff --git a/www/tests/markdown-routes.ts b/www/tests/markdown-routes.ts index 52e12a7b3..ee7e121d6 100644 --- a/www/tests/markdown-routes.ts +++ b/www/tests/markdown-routes.ts @@ -1,6 +1,6 @@ import { assertEquals, assertMatch, assertStringIncludes } from "@std/assert"; import { beforeAll, describe, it } from "@effectionx/bdd"; -import { until } from "effection"; +import { fetch, type FetchResponse } from "@effectionx/fetch"; import { parse } from "@frontside/configliere"; import { useSite } from "../main.tsx"; @@ -43,7 +43,7 @@ function options() { }; } -function markdown(response: Response) { +function markdown(response: FetchResponse) { assertEquals(response.status, 200); assertEquals( response.headers.get("Content-Type"), @@ -73,15 +73,10 @@ describe("the markdown an agent reads", () => { // `site` is where the documents are fetched from; the urls inside them // belong to whatever the site is configured to advertise, which is not // necessarily the address these cases are talking to - function get(path: string) { - return until( - fetch(new URL(path, site)).then( - async (response): Promise<[Response, string]> => [ - response, - await response.text(), - ], - ), - ); + function* get(path: string) { + let response = yield* fetch(new URL(path, site)); + + return [response, yield* response.text()] as const; } it("/AGENTS.md serves the behavioral contract", function* () {