|
| 1 | +import assert from "node:assert/strict"; |
| 2 | +import { execFileSync } from "node:child_process"; |
| 3 | +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; |
| 4 | +import { tmpdir } from "node:os"; |
| 5 | +import { dirname, resolve } from "node:path"; |
| 6 | +import { test } from "node:test"; |
| 7 | + |
| 8 | +const script = resolve("scripts/render-preview-comment.ts"); |
| 9 | +const environment = Object.fromEntries( |
| 10 | + Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")), |
| 11 | +); |
| 12 | + |
| 13 | +function git(root: string, ...arguments_: string[]): string { |
| 14 | + return execFileSync("git", arguments_, { cwd: root, encoding: "utf8", env: environment }).trim(); |
| 15 | +} |
| 16 | + |
| 17 | +async function repository(changedFile: string): Promise<{ base: string; root: string }> { |
| 18 | + const root = await mkdtemp(resolve(tmpdir(), "preview-comment-")); |
| 19 | + git(root, "init", "--quiet"); |
| 20 | + git(root, "config", "user.email", "test@example.invalid"); |
| 21 | + git(root, "config", "user.name", "Test"); |
| 22 | + await writeFile(resolve(root, "seed"), "seed"); |
| 23 | + git(root, "add", "."); |
| 24 | + git(root, "commit", "--quiet", "-m", "seed"); |
| 25 | + const base = git(root, "rev-parse", "HEAD"); |
| 26 | + await mkdir(dirname(resolve(root, changedFile)), { recursive: true }); |
| 27 | + await writeFile(resolve(root, changedFile), "changed"); |
| 28 | + git(root, "add", "."); |
| 29 | + git(root, "commit", "--quiet", "-m", "change"); |
| 30 | + return { base, root }; |
| 31 | +} |
| 32 | + |
| 33 | +async function render( |
| 34 | + root: string, |
| 35 | + base: string, |
| 36 | + kind: string, |
| 37 | + directory: string, |
| 38 | +): Promise<string> { |
| 39 | + execFileSync("node", [script, kind, directory, "https://preview.example/", base, "comment.md"], { |
| 40 | + cwd: root, |
| 41 | + stdio: "pipe", |
| 42 | + env: { |
| 43 | + ...environment, |
| 44 | + GITHUB_SERVER_URL: "https://github.com", |
| 45 | + GITHUB_REPOSITORY: "example/project", |
| 46 | + GITHUB_RUN_ID: "123", |
| 47 | + GITHUB_SHA: "not-the-checked-out-commit", |
| 48 | + }, |
| 49 | + }); |
| 50 | + return readFile(resolve(root, "comment.md"), "utf8"); |
| 51 | +} |
| 52 | + |
| 53 | +void test("links only stories from changed files to their canvases", async () => { |
| 54 | + const { base, root } = await repository("webapp/src/Button.stories.tsx"); |
| 55 | + await mkdir(resolve(root, "build")); |
| 56 | + await writeFile( |
| 57 | + resolve(root, "build/index.json"), |
| 58 | + JSON.stringify({ |
| 59 | + entries: { |
| 60 | + "button--primary": { |
| 61 | + importPath: "./src/Button.stories.tsx", |
| 62 | + name: "Primary [default]", |
| 63 | + title: "UI/Button", |
| 64 | + type: "story", |
| 65 | + }, |
| 66 | + "button--unsafe": { |
| 67 | + importPath: "./src/Button.stories.tsx", |
| 68 | + name: "Unsafe\n<img>", |
| 69 | + title: "UI/Button", |
| 70 | + type: "story", |
| 71 | + }, |
| 72 | + "button--docs": { |
| 73 | + importPath: "./src/Button.stories.tsx", |
| 74 | + name: "Docs", |
| 75 | + title: "UI/Button", |
| 76 | + type: "docs", |
| 77 | + }, |
| 78 | + "other--unchanged": { |
| 79 | + importPath: "./src/Other.stories.tsx", |
| 80 | + name: "Unchanged", |
| 81 | + title: "Other", |
| 82 | + type: "story", |
| 83 | + }, |
| 84 | + }, |
| 85 | + }), |
| 86 | + ); |
| 87 | + const comment = await render(root, base, "storybook", "build"); |
| 88 | + assert.match(comment, /Stories in changed files/); |
| 89 | + assert.ok(comment.includes("UI/Button — Primary \\[default\\]")); |
| 90 | + assert.match(comment, /\?path=\/story\/button--primary/); |
| 91 | + assert.doesNotMatch(comment, /button--docs/); |
| 92 | + assert.doesNotMatch(comment, /other--unchanged/); |
| 93 | + assert.ok(comment.includes("Unsafe <img>")); |
| 94 | + assert.doesNotMatch(comment, /Unsafe\n/); |
| 95 | + const sha = git(root, "rev-parse", "HEAD"); |
| 96 | + assert.ok( |
| 97 | + comment.includes( |
| 98 | + `Built from [\`${sha.slice(0, 7)}\`](<https://github.com/example/project/commit/${sha}>)`, |
| 99 | + ), |
| 100 | + ); |
| 101 | + assert.ok( |
| 102 | + comment.includes("[Build logs](<https://github.com/example/project/actions/runs/123>)"), |
| 103 | + ); |
| 104 | + assert.doesNotMatch(comment, /not-the-checked-out-commit/); |
| 105 | +}); |
| 106 | + |
| 107 | +void test("limits long story lists", async () => { |
| 108 | + const { base, root } = await repository("webapp/src/Button.stories.tsx"); |
| 109 | + await mkdir(resolve(root, "build")); |
| 110 | + await writeFile( |
| 111 | + resolve(root, "build/index.json"), |
| 112 | + JSON.stringify({ |
| 113 | + entries: Object.fromEntries( |
| 114 | + Array.from({ length: 28 }, (_, index) => [ |
| 115 | + `button--${index}`, |
| 116 | + { |
| 117 | + importPath: "./src/Button.stories.tsx", |
| 118 | + name: `Example ${index}`, |
| 119 | + title: "Button", |
| 120 | + type: "story", |
| 121 | + }, |
| 122 | + ]), |
| 123 | + ), |
| 124 | + }), |
| 125 | + ); |
| 126 | + const comment = await render(root, base, "storybook", "build"); |
| 127 | + assert.match(comment, /Stories in changed files \(25 of 28\)/); |
| 128 | + assert.match(comment, /3 more are available in the full preview/); |
| 129 | +}); |
| 130 | + |
| 131 | +void test("does not claim files are unchanged when they have no published stories", async () => { |
| 132 | + const { base, root } = await repository("webapp/src/Button.stories.tsx"); |
| 133 | + await mkdir(resolve(root, "build")); |
| 134 | + await writeFile(resolve(root, "build/index.json"), JSON.stringify({ entries: {} })); |
| 135 | + const comment = await render(root, base, "storybook", "build"); |
| 136 | + assert.match( |
| 137 | + comment, |
| 138 | + /Stories in changed files\n\nNo published stories found in changed files\./, |
| 139 | + ); |
| 140 | +}); |
| 141 | + |
| 142 | +void test("renders safe changed Docusaurus routes once", async () => { |
| 143 | + const { base, root } = await repository("docs/user/getting-started.mdx"); |
| 144 | + await mkdir(resolve(root, "metadata/nested"), { recursive: true }); |
| 145 | + await writeFile( |
| 146 | + resolve(root, "metadata/nested/page.json"), |
| 147 | + JSON.stringify({ |
| 148 | + permalink: "/user/start-(here)", |
| 149 | + source: "@site/user/getting-started.mdx", |
| 150 | + title: "Start [here]", |
| 151 | + }), |
| 152 | + ); |
| 153 | + await writeFile( |
| 154 | + resolve(root, "metadata/nested/duplicate.json"), |
| 155 | + JSON.stringify({ |
| 156 | + permalink: "/user/start-(here)", |
| 157 | + source: "@site/user/getting-started.mdx", |
| 158 | + title: "Start [here]", |
| 159 | + }), |
| 160 | + ); |
| 161 | + await writeFile( |
| 162 | + resolve(root, "metadata/nested/unchanged.json"), |
| 163 | + JSON.stringify({ |
| 164 | + permalink: "/user/other", |
| 165 | + source: "@site/user/other.mdx", |
| 166 | + title: "Unchanged", |
| 167 | + }), |
| 168 | + ); |
| 169 | + await writeFile( |
| 170 | + resolve(root, "metadata/nested/external.json"), |
| 171 | + JSON.stringify({ |
| 172 | + permalink: "https://attacker.example/phishing", |
| 173 | + source: "@site/user/getting-started.mdx", |
| 174 | + title: "External", |
| 175 | + }), |
| 176 | + ); |
| 177 | + const comment = await render(root, base, "docs", "metadata"); |
| 178 | + assert.match(comment, /Changed pages/); |
| 179 | + const link = "[Start \\[here\\]](<https://preview.example/user/start-(here)>)"; |
| 180 | + assert.equal(comment.split(link).length - 1, 1); |
| 181 | + assert.doesNotMatch(comment, /attacker|External/); |
| 182 | + assert.doesNotMatch(comment, /Unchanged/); |
| 183 | +}); |
| 184 | + |
| 185 | +void test("rejects an invalid Storybook index instead of publishing an empty result", async () => { |
| 186 | + const { base, root } = await repository("webapp/src/Button.stories.tsx"); |
| 187 | + await mkdir(resolve(root, "build")); |
| 188 | + for (const index of [ |
| 189 | + {}, |
| 190 | + { entries: [] }, |
| 191 | + { entries: null }, |
| 192 | + { entries: { "button--primary": { type: "story" } } }, |
| 193 | + ]) { |
| 194 | + await writeFile(resolve(root, "build/index.json"), JSON.stringify(index)); |
| 195 | + await assert.rejects( |
| 196 | + render(root, base, "storybook", "build"), |
| 197 | + /Storybook (index\.entries|entry button--primary\.importPath) must be/, |
| 198 | + ); |
| 199 | + } |
| 200 | +}); |
| 201 | + |
| 202 | +void test("does not claim docs are unchanged when a changed page is unpublished", async () => { |
| 203 | + const { base, root } = await repository("docs/user/draft.mdx"); |
| 204 | + await mkdir(resolve(root, "metadata")); |
| 205 | + const comment = await render(root, base, "docs", "metadata"); |
| 206 | + assert.match(comment, /Changed pages\n\nNo published pages found in changed files\./); |
| 207 | +}); |
0 commit comments