Files
opencode/packages/desktop/script/beta-notes.test.ts

299 lines
12 KiB
TypeScript

import { afterAll, expect, test } from "bun:test"
import { $ } from "bun"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { BetaNotes } from "./beta-notes"
test.each([
[
"feat(app): merge projects across servers in the location dialog",
{ type: "feat", scope: "app", summary: "merge projects across servers in the location dialog" },
],
[
"fix(desktop): disable animated wheel scrolling on linux (#57120)",
{ type: "fix", scope: "desktop", summary: "disable animated wheel scrolling on linux", pr: 57120 },
],
["perf: cache model list", { type: "perf", summary: "cache model list" }],
[
"Feat(core)!: drop legacy sessions",
{ type: "feat", scope: "core", summary: "drop legacy sessions", breaking: true },
],
[
"fix(app): keep (#1) in the middle of a summary",
{ type: "fix", scope: "app", summary: "keep (#1) in the middle of a summary" },
],
["chore(sdk): regenerate types", undefined],
["refactor(app): split dialog", undefined],
["docs: update guide", undefined],
["test(app): cover dialog", undefined],
["ci: cache bun", undefined],
["Merge branch 'dev' into beta", undefined],
["fix the thing without a type", undefined],
["fix(app): (#12)", undefined],
])("parses %p", (subject, expected) => {
expect(BetaNotes.parseSubject(subject)).toEqual(expected)
})
const repos: string[] = []
afterAll(() => Promise.all(repos.map((dir) => rm(dir, { recursive: true, force: true }))))
async function repo(subjects: string[]) {
const dir = await mkdtemp(path.join(tmpdir(), "beta-notes-"))
repos.push(dir)
await $`git init -q -b beta && git config user.email a@b.c && git config user.name Ada`.cwd(dir).quiet()
// One fast-import writes every commit, where a commit per process is slow on Windows.
const stream = subjects
.map((subject, i) =>
[
"commit refs/heads/beta",
`committer Ada <a@b.c> ${1_700_000_000 + i} +0000`,
`data ${Buffer.byteLength(subject)}`,
subject,
"",
].join("\n"),
)
.join("")
await $`git fast-import --quiet < ${new Response(stream)}`.cwd(dir).quiet()
await $`git reset -q --hard beta`.cwd(dir).quiet()
return { dir, shas: (await $`git rev-list --reverse HEAD`.cwd(dir).text()).trim().split("\n") }
}
test("embeds the build's commit, its history and the notes of its non-merge feat and fix commits", async () => {
const git = await repo([
"feat(app): old feature",
"fix(app): first fix (#10)",
"chore: bump",
"feat(desktop): new dialog",
])
await $`git checkout -q -b side HEAD~1 && git commit -q --allow-empty -m ${"fix(app): side fix"} && git checkout -q beta && git merge -q --no-ff side -m ${"fix(app): merge side"}`
.cwd(git.dir)
.quiet()
const head = (await $`git rev-parse HEAD`.cwd(git.dir).text()).trim()
const side = (await $`git rev-parse side`.cwd(git.dir).text()).trim()
const short = (sha: string) => sha.slice(0, 12)
const embedded = BetaNotes.embed({ head: "HEAD", cwd: git.dir })
expect(embedded.sha).toBe(head)
expect(embedded.commits).toHaveLength(6)
expect(embedded.commits[0]).toBe(short(head))
const author = { name: "Ada" }
expect(embedded.notes).toEqual([
{ type: "fix", scope: "app", summary: "side fix", sha: short(side), author },
{ type: "feat", scope: "desktop", summary: "new dialog", sha: short(git.shas[3]!), author },
{ type: "fix", scope: "app", summary: "first fix", pr: 10, sha: short(git.shas[1]!), author },
{ type: "feat", scope: "app", summary: "old feature", sha: short(git.shas[0]!), author },
])
})
test("embeds at most the newest 200 commits", async () => {
const git = await repo(Array.from({ length: 205 }, (_, i) => `fix(app): fix ${i}`))
const embedded = BetaNotes.embed({ head: "HEAD", cwd: git.dir })
expect(embedded.commits).toHaveLength(200)
expect(embedded.notes[0]?.summary).toBe("fix 204")
expect(embedded.notes.at(-1)?.summary).toBe("fix 5")
})
const TEMPLATE = `### Issue for this PR
Closes #1
### Type of change
- [x] Bug fix
### What does this PR do?
Keep the composer's last line visible. Shift+Enter now scrolls far enough to clear the [fade](https://example.com).
A third sentence is left out.
### How did you verify your code works?
Tests.`
test.each([
[
"the template's What does this PR do? section",
TEMPLATE,
"Keep the composer's last line visible. Shift+Enter now scrolls far enough to clear the fade.",
],
[
"a Summary list's first item",
"## Summary\n- Restore the **standard** border (`composer.tsx`).\n- Keep the row.",
"Restore the standard border.",
],
[
"the text before the first heading",
"Home showed a loading state.\n\n### Changes\n\n- Cache the list.",
"Home showed a loading state.",
],
[
"a Changes section when nothing comes first",
"### Why\n\nIt was slow.\n\n### Changes\n\nThe list stays cached.",
"The list stays cached.",
],
[
"nothing that introduces a code block",
"### What does this PR do?\n\nThe query was:\n\n```sql\nSELECT 1\n```",
undefined,
],
["nothing that repeats the subject", "Fix the thing.", undefined],
[
"past the template's filler lines",
"**Issue for this PR:** N/A\nCloses #\nType of change: Bug fix\n- [x] Bug fix\n<!-- note -->\n\nThe dialog opens at the top.",
"The dialog opens at the top.",
],
[
"the whole sentences that fit under the cap",
`Short first sentence. ${"Long ".repeat(50)}second.`,
"Short first sentence.",
],
[
"a long sentence cut at a word, with an ellipsis",
`Unifies the card surface, ${"and more words ".repeat(20)}end.`,
`Unifies the card surface, ${"and more words ".repeat(14).trim()}…`,
],
["nothing from an empty body", "", undefined],
])("describes %s", (_, body, expected) => {
expect(BetaNotes.describe(body, "fix the thing")).toBe(expected)
})
const notes = (): ReturnType<typeof BetaNotes.embed> => ({
sha: "a".repeat(40),
commits: ["aaaaaaaaaaaa", "bbbbbbbbbbbb"],
notes: [
{ type: "fix", summary: "keep the last line visible", pr: 10, sha: "aaaaaaaaaaaa", author: { name: "Ada" } },
{ type: "feat", summary: "local change", sha: "bbbbbbbbbbbb", author: { name: "Ada" } },
],
})
test("adds each pull request's author and description from one GitHub query", async () => {
const requests: RequestInit[] = []
const enriched = await BetaNotes.enrich(notes(), {
token: "token",
fetch: async (_, init) => {
requests.push(init)
return Response.json({
data: {
repository: {
pr10: {
number: 10,
body: TEMPLATE,
author: {
login: "ada",
name: "Ada Lovelace",
avatarUrl: "https://avatars.githubusercontent.com/u/1?s=48",
},
},
},
},
})
},
})
expect(requests).toHaveLength(1)
expect(enriched.notes).toEqual([
{
...notes().notes[0]!,
author: { name: "Ada Lovelace", login: "ada", avatar: "https://avatars.githubusercontent.com/u/1?s=48" },
description: "Keep the composer's last line visible. Shift+Enter now scrolls far enough to clear the fade.",
},
notes().notes[1],
])
})
test("keeps only GitHub's public avatars, and the login when there is no name", async () => {
const enriched = await BetaNotes.enrich(notes(), {
token: "token",
fetch: async () =>
Response.json({
data: {
repository: {
pr10: { number: 10, body: "", author: { login: "bot", avatarUrl: "http://example.com/a.png" } },
},
},
}),
})
expect(enriched.notes[0]?.author).toEqual({ name: "bot", login: "bot" })
})
test.each([
["no token", undefined, async () => Response.json({})],
["a failed request", "token", async () => new Response("rate limited", { status: 403 })],
["a network error", "token", async () => Promise.reject(new Error("offline"))],
["an unexpected response", "token", async () => Response.json({ errors: [{ message: "bad" }] })],
])("keeps the commit authors and subjects with %s", async (_, token, request) => {
expect(await BetaNotes.enrich(notes(), { token, fetch: request })).toEqual(notes())
})
const SVG = `<svg viewBox="0 0 240 72"><rect width="40" height="8" fill="currentColor"><animate attributeName="width" values="40;80;40" dur="3s" repeatCount="indefinite"/></rect></svg>`
const withVisual = (svg: string) =>
`### What does this PR do?\n\nIt grows.\n\n### Visual\n\n\`\`\`svg\n${svg}\n\`\`\`\n\n### Checklist`
test("rebuilds a visual with its namespace, and a still without its animation", () => {
expect(BetaNotes.visual(withVisual(SVG))).toEqual({
animated: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72"><rect width="40" height="8" fill="currentColor"><animate attributeName="width" values="40;80;40" dur="3s" repeatCount="indefinite"/></rect></svg>`,
still: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72"><rect width="40" height="8" fill="currentColor"/></svg>`,
})
})
test("drops scripts, foreign objects, images, handlers, outside links and SMIL that animates a link", () => {
const rebuilt = BetaNotes.visual(
withVisual(
`<svg onload="alert(1)"><!-- note --><script>alert(1)</script><foreignObject><div/></foreignObject><image href="https://example.com/a.png"/><use xlink:href="https://example.com/a.svg#x"/><use xlink:href="#x"/><rect onclick="x()"/><animate attributeName="href" values="https://example.com"/><set attributeName="xlink:href" to="https://example.com"/></svg>`,
),
)
expect(rebuilt?.animated).toBe(`<svg xmlns="http://www.w3.org/2000/svg"><use/><use href="#x"/><rect/></svg>`)
})
test("drops a still's keyframes and animations, even important or inline ones", () => {
const rebuilt = BetaNotes.visual(
withVisual(
`<svg><style><![CDATA[.a{fill:red;animation:spin 1s infinite!important}@keyframes spin{from{opacity:0}to{opacity:1}}@media (min-width:1px){.b{transition:opacity 1s}}]]></style><rect class="a" style="animation: spin 1s !important; stroke: blue"/></svg>`,
),
)
expect(rebuilt?.still).toBe(
`<svg xmlns="http://www.w3.org/2000/svg"><style>.a{fill:red}@media (min-width:1px){.b{}}</style><rect class="a" style="stroke: blue"/></svg>`,
)
expect(rebuilt?.animated).toContain("@keyframes spin")
})
test.each([
["no Visual section", "### What does this PR do?\n\n```svg\n<svg></svg>\n```"],
["a root that is not an svg", withVisual("<div><svg></svg></div>")],
["more than one svg root", withVisual("<svg></svg><svg></svg>")],
["a doctype", withVisual('<!DOCTYPE svg [<!ENTITY x "y">]><svg></svg>')],
["an @import", withVisual('<svg><style>@import "https://example.com/a.css";</style></svg>')],
["a CSS-escaped @import", withVisual('<svg><style>@\\69mport "https://example.com/a.css";</style></svg>')],
["a CSS-escaped url()", withVisual('<svg><rect style="fill:u\\72l(https://example.com/p)"/></svg>')],
["an outside url() in an attribute", withVisual('<svg><rect fill="url(https://example.com/p)"/></svg>')],
[
"an outside url() in an animation",
withVisual('<svg><animate attributeName="fill" to="url(https://example.com/p)"/></svg>'),
],
["an entity-encoded url()", withVisual('<svg><rect fill="&#117;rl(https://example.com/p)"/></svg>')],
["more than 8 KB", withVisual(`<svg>${'<rect width="1"/>'.repeat(600)}</svg>`)],
])("refuses a visual with %s", (_, body) => {
expect(BetaNotes.visual(body)).toBeUndefined()
})
test("keeps the visual's code out of the description", () => {
expect(BetaNotes.describe(`\`\`\`svg\n${SVG}\n\`\`\`\n\nIt grows.`, "grow")).toBe("It grows.")
})