From f687edf8b641ab753a6233750f96cd02585f7687 Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 1 Nov 2026 01:38:41 +0800 Subject: [PATCH] test(scripts): the Go-limits HTML reader, which is the half that broke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The allowance table is GENERATED from the vendor's page, and the reader had to learn a new shape when the docs moved under `/v2/docs/console/` and started serving HTML. That conversion lived inside a top-level-await script, so nothing could exercise it — a conversion nobody can exercise is a conversion nobody knows works, and its failure mode is quiet: a page it cannot read writes a generated file with every allowance missing and still looks valid. It is now `scripts/go-limits-html.ts`, and `test/go-limits-html.test.ts` holds it to four things: - **the cells**: tags stripped, trimmed, `th` read as a cell so a header row is a row; - **the selection**: a table is taken by its HEADER ("Monthly limit"), not by its position or its shape — the page carries other six-column tables, and reading one as an allowance is the failure this exists for; - **the refusal**: fewer than two such tables throws, because an empty document would read as "no models" rather than as a broken source; - **the round trip**: the markdown it builds goes through the REAL `parseGoLimitsDoc` and yields the allowances the meter reads. Either half can be wrong alone; only that case exercises both. Planted the filter's removal, the header case fails with the request count it should have ignored. `pnpm run limits:shim` still reports "up to date (28 models)" against the live page, so the extraction is wired end to end. --- scripts/generate-go-limits.ts | 62 +------------ scripts/go-limits-html.ts | 78 ++++++++++++++++ test/go-limits-html.test.ts | 166 ++++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 61 deletions(-) create mode 100644 scripts/go-limits-html.ts create mode 100644 test/go-limits-html.test.ts diff --git a/scripts/generate-go-limits.ts b/scripts/generate-go-limits.ts index 490db224..9aad8f63 100644 --- a/scripts/generate-go-limits.ts +++ b/scripts/generate-go-limits.ts @@ -65,6 +65,7 @@ import { GO_LIMITS_DOC_URL, parseGoLimitsDoc, } from "../src/go-limits.ts"; +import { GO_LIMITS_DOC_MOVED_TO, htmlToDoc } from "./go-limits-html.ts"; const TARGET = new URL("../src/go-limits-data.ts", import.meta.url); const argv = process.argv.slice(2); @@ -72,67 +73,6 @@ const shouldWrite = argv.includes("--write"); const fromIndex = argv.indexOf("--from"); const fromPath = fromIndex === -1 ? undefined : argv[fromIndex + 1]; -/** Where the docs moved to, named in the failure so it is actionable. */ -const GO_LIMITS_DOC_MOVED_TO = "https://opencode.ai/v2/docs/console/go/"; - -/** - * Strip tags from a table's cells, row by row. - * - * @param html - one `` element's inner HTML. - * @returns one array of cell strings per ``. - */ -const tableRows = (html: string): string[][] => - [...html.matchAll(/]*>(.*?)<\/tr>/gsu)].map((row) => - [...(row[1] ?? "").matchAll(/]*>(.*?)<\/t[dh]>/gsu)].map((cell) => - (cell[1] ?? "").replaceAll(/<[^>]+>/gu, "").trim() - ) - ); - -/** - * Turn the moved page's HTML into the markdown the parser above reads. - * - * The vendor moved the docs and the new page serves HTML, so the reader needs a - * shape it recognises rather than a parser of its own. The page carries one - * `Model | Input | Output | Cached Read | Cached Write | Monthly limit` table per - * plan - Go first, Go Plus second, measured - and the request-estimate tables - * below them repeat model names with no dollar column at all, which is exactly - * what the parser is written to stop before. So the two dollar tables become two - * `` sections and nothing else is passed on. - * - * @param html - the page's HTML. - * @returns markdown in the shape `parseGoLimitsDoc` expects. - * @throws {Error} when neither dollar table is present, because an empty document - * would silently read as "no models" rather than as a broken source. - */ -const htmlToDoc = (html: string): string => { - const dollarTables = [...html.matchAll(/]*>(.*?)<\/table>/gsu)] - .map((table) => tableRows(table[1] ?? "")) - .filter((rows) => - (rows[0] ?? []).at(-1)?.toLowerCase().includes("monthly limit") - ); - if (dollarTables.length < 2) { - throw new Error( - `go-limits: ${GO_LIMITS_DOC_MOVED_TO} carried ${dollarTables.length} monthly-limit table(s), expected 2` - ); - } - const [go, goPlus] = dollarTables; - const section = (label: string, rows: string[][]): string => - [ - ``, - "| Model | Input | Output | Cached Read | Cached Write | Monthly limit |", - "| --- | --- | --- | --- | --- | --- |", - ...(rows ?? []) - .slice(1) - .filter((cells) => cells.length >= 6 && cells[0] !== undefined) - .map( - (cells) => - `| ${cells[0]} | ${cells.slice(1, -1).join(" | ")} | **${cells.at(-1) ?? ""}** |` - ), - "", - ].join("\n"); - return [section("Go", go ?? []), section("Go Plus", goPlus ?? [])].join("\n"); -}; - const readDoc = async (): Promise => { if (fromPath !== undefined) { return readFileSync(fromPath, "utf8"); diff --git a/scripts/go-limits-html.ts b/scripts/go-limits-html.ts new file mode 100644 index 00000000..2ea19e54 --- /dev/null +++ b/scripts/go-limits-html.ts @@ -0,0 +1,78 @@ +/** + * The vendor's Go-plan page, read as the markdown the limits parser expects. + * + * Extracted from `generate-go-limits.ts` so it can be tested without a network + * call, and because this is the half that BROKE: the docs moved under + * `/v2/docs/console/` and started serving HTML, so the reader had to learn a new + * shape. A conversion nobody can exercise is a conversion nobody knows works. + * + * The page carries one `Model | Input | Output | Cached Read | Cached Write | + * Monthly limit` table per plan — Go first, Go Plus second, measured — and the + * request-estimate tables below them repeat model names with no dollar column at + * all, which is exactly what the parser is written to stop before. + * + * @module scripts/go-limits-html + */ + +/** Where the page lives, for the error message. */ +export const GO_LIMITS_DOC_MOVED_TO = "https://opencode.ai/v2/docs/console/go/"; + +/** The heading of the table this reader takes, as the page spells it. */ +const MONTHLY_LIMIT_HEADER = "monthly limit"; + +/** + * Strip tags from a table's cells, row by row. + * + * @param html - one `
` element's inner HTML. + * @returns one array of cell strings per ``. + */ +export const tableRows = (html: string): string[][] => + [...html.matchAll(/]*>(.*?)<\/tr>/gsu)].map((row) => + [...(row[1] ?? "").matchAll(/]*>(.*?)<\/t[dh]>/gsu)].map((cell) => + (cell[1] ?? "").replaceAll(/<[^>]+>/gu, "").trim() + ) + ); + +/** The markdown header the parser reads, repeated for each plan's section. */ +const MARKDOWN_HEADER = [ + "| Model | Input | Output | Cached Read | Cached Write | Monthly limit |", + "| --- | --- | --- | --- | --- | --- |", +]; + +/** + * Turn the moved page's HTML into the markdown `parseGoLimitsDoc` reads. + * + * @param html - the page's HTML. + * @returns markdown in the shape the parser expects, one `` per plan. + * @throws {Error} when the two dollar tables are not both present, because an + * empty document would silently read as "no models" rather than as a broken + * source — and the generated file would then be written with every allowance + * missing. + */ +export const htmlToDoc = (html: string): string => { + const dollarTables = [...html.matchAll(/]*>(.*?)<\/table>/gsu)] + .map((table) => tableRows(table[1] ?? "")) + .filter((rows) => + (rows[0] ?? []).at(-1)?.toLowerCase().includes(MONTHLY_LIMIT_HEADER) + ); + if (dollarTables.length < 2) { + throw new Error( + `go-limits: ${GO_LIMITS_DOC_MOVED_TO} carried ${dollarTables.length} monthly-limit table(s), expected 2` + ); + } + const [go, goPlus] = dollarTables; + const section = (label: string, rows: readonly string[][]): string => + [ + ``, + ...MARKDOWN_HEADER, + ...rows + .slice(1) + .filter((cells) => cells.length >= 6 && cells[0] !== undefined) + .map( + (cells) => + `| ${cells[0]} | ${cells.slice(1, -1).join(" | ")} | **${cells.at(-1) ?? ""}** |` + ), + "", + ].join("\n"); + return [section("Go", go ?? []), section("Go Plus", goPlus ?? [])].join("\n"); +}; diff --git a/test/go-limits-html.test.ts b/test/go-limits-html.test.ts new file mode 100644 index 00000000..f1efe377 --- /dev/null +++ b/test/go-limits-html.test.ts @@ -0,0 +1,166 @@ +/** + * The vendor's HTML page read as the markdown the limits parser expects. + * + * This is the half that broke: the docs moved under `/v2/docs/console/` and began + * serving HTML, so the reader had to learn a new shape — and a conversion nobody + * can exercise is a conversion nobody knows works. It is also the path a CI + * freshness job depends on, where a quiet failure writes a table with every + * allowance missing rather than failing. + * + * The last case is the one that matters most: it feeds the REAL parser, because + * the converter and the parser are two halves of one path and either can be + * wrong alone. + * + * @module test/go-limits-html.test + */ + +import { describe, expect, it } from "vitest"; + +import { htmlToDoc, tableRows } from "../scripts/go-limits-html.ts"; +import { parseGoLimitsDoc } from "../src/go-limits.ts"; + +/** + * A monthly-limit table, as the page renders one. + * + * Six columns, measured on the real page: `Model | input | output | cached + * read | cached write | Monthly limit`. The converter keeps only rows of six or + * more cells, so a fixture with fewer would prove nothing about the real shape. + */ +const monthlyTable = (rows: readonly [string, string, string][]): string => + [ + "
", + "", + "", + ...rows.map( + ([model, input, limit]) => + `` + ), + "", + "
ModelInputOutputCached ReadCached WriteMonthly limit
${model}${input}$1.00$0.10-${limit}
", + ].join(""); + +/** A request-estimate table: model names, no dollar column. */ +const estimateTable = (): string => + [ + "", + "", + "", + "
ModelRequests / 5 hours
MiMo-V2.6-Flash1,200
", + ].join(""); + +describe("reading a table's cells", () => { + it("strips the tags and trims each cell", () => { + expect( + tableRows( + "MiMo-V2.6-Pro\n $1.40\n" + ) + ).toEqual([["MiMo-V2.6-Pro", "$1.40"]]); + }); + + it("reads th as a cell, so a header row is a row", () => { + expect(tableRows("ModelMonthly limit")).toEqual([ + ["Model", "Monthly limit"], + ]); + }); +}); + +describe("turning the page into the parser's markdown", () => { + it("takes the two monthly-limit tables, in the page's order", () => { + const html = [ + monthlyTable([ + ["MiMo-V2.6-Pro", "$0.50", "$60"], + ["Grok 4.7", "$1.00", "$15"], + ]), + monthlyTable([ + ["MiMo-V2.6-Pro", "$0.50", "$120"], + ["Grok 4.7", "$1.00", "$60"], + ]), + ].join(""); + const doc = htmlToDoc(html); + // Two sections, in the order the page carries them: Go, then Go Plus. + expect(doc.indexOf('')).toBeLessThan( + doc.indexOf('') + ); + expect(doc).toContain( + "| MiMo-V2.6-Pro | $0.50 | $1.00 | $0.10 | - | **$60** |" + ); + expect(doc).toContain( + "| MiMo-V2.6-Pro | $0.50 | $1.00 | $0.10 | - | **$120** |" + ); + }); + + it("leaves the request-estimate tables behind", () => { + // Their names repeat the dollar tables' names with no allowance column. Read + // as allowances, they would invent one out of a request count. + const html = + monthlyTable([["MiMo-V2.6-Pro", "$0.50", "$60"]]) + + estimateTable() + + monthlyTable([["MiMo-V2.6-Pro", "$0.50", "$120"]]); + const doc = htmlToDoc(html); + expect(doc).not.toContain("1,200"); + expect(doc.split(" { + // The page carries more than two six-column tables, and only the two whose + // last column says "Monthly limit" are allowances. Choosing by position + // would read a request estimate as one — which is the failure that made this + // converter need a test at all. + const sameShapeOtherTable = [ + "", + "", + "", + "
ModelInputOutputCached ReadCached WriteRequests / 5h
MiMo-V2.6-Flash$0.1$0.2$0.01-1,200
", + ].join(""); + const html = + sameShapeOtherTable + + monthlyTable([["MiMo-V2.6-Pro", "$0.50", "$60"]]) + + sameShapeOtherTable + + monthlyTable([["MiMo-V2.6-Pro", "$0.50", "$120"]]); + const doc = htmlToDoc(html); + expect(doc).not.toContain("1,200"); + expect(doc).toContain("**$60**"); + expect(doc).toContain("**$120**"); + }); + + it("refuses a page with fewer than two monthly-limit tables", () => { + // The failure mode a silent conversion would produce is worse than a throw: + // the generated file would carry no allowances at all and still look valid. + expect(() => htmlToDoc(monthlyTable([["a", "$1", "$60"]]))).toThrow( + /carried 1 monthly-limit table/ + ); + expect(() => htmlToDoc("")).toThrow(/expected 2/u); + }); +}); + +describe("the converter and the parser agree", () => { + it("round-trips a page into the allowances the meter reads", () => { + // The coupling: the converter writes the markdown and `parseGoLimitsDoc` + // reads it. Either half can be wrong alone, and only this case exercises both. + const html = [ + monthlyTable([ + ["MiMo-V2.6-Pro", "$0.50", "$60"], + ["Grok 4.7", "$1.00", "$15"], + ]), + monthlyTable([ + ["MiMo-V2.6-Pro", "$0.50", "$120"], + ["Grok 4.7", "$1.00", "$60"], + ]), + ].join(""); + const { limits, unmapped } = parseGoLimitsDoc(htmlToDoc(html)); + expect(unmapped).toEqual([]); + // Keyed by the NORMALIZED label — the id the gateway uses — with the + // vendor's spelling kept for the display: the two planes' tables spell the + // same model the same way, and one of them is what a reader sees. + expect(limits.get("mimo-v2.6-pro")).toEqual({ + go: 60, + goPlus: 120, + name: "MiMo-V2.6-Pro", + }); + expect(limits.get("grok-4.7")).toEqual({ + go: 15, + goPlus: 60, + name: "Grok 4.7", + }); + }); +});