|
1 | 1 | import { existsSync } from 'node:fs' |
2 | | -import { readFile } from 'node:fs/promises' |
3 | | -import { join } from 'node:path' |
| 2 | +import { readdir, readFile } from 'node:fs/promises' |
| 3 | +import { join, relative } from 'node:path' |
4 | 4 | import { describe, expect, it } from 'vitest' |
5 | 5 |
|
6 | 6 | // Resolves to the generated static deploy artifact. `nuxt generate` writes to |
@@ -105,3 +105,69 @@ describe('Built pages have required OG/SEO meta', () => { |
105 | 105 | } |
106 | 106 | }) |
107 | 107 | }) |
| 108 | + |
| 109 | +// Catches the class of bug where a consumer (listing <img>, JSON-LD image, etc.) |
| 110 | +// predicts an /_og/s/o_<hash>.png URL that the producer never wrote — i.e. the |
| 111 | +// hash inputs drifted between defineOgImage and the predictor. nuxt-og-image |
| 112 | +// logs "Skipped N orphaned OG image hash URL" during prerender for these, but |
| 113 | +// doesn't fail the build. This test does. |
| 114 | +async function walkHtml(dir: string): Promise<string[]> { |
| 115 | + const out: string[] = [] |
| 116 | + for (const entry of await readdir(dir, { withFileTypes: true })) { |
| 117 | + const p = join(dir, entry.name) |
| 118 | + if (entry.isDirectory()) out.push(...(await walkHtml(p))) |
| 119 | + else if (entry.name.endsWith('.html')) out.push(p) |
| 120 | + } |
| 121 | + return out |
| 122 | +} |
| 123 | + |
| 124 | +function extractStaticOgRefs(html: string, base: string): string[] { |
| 125 | + const refs = new Set<string>() |
| 126 | + // Any quoted attribute carrying an /_og/s/o_<hash>.png path (covers <img src>, |
| 127 | + // <link href>, <meta content>, srcset entries, etc.). |
| 128 | + for (const m of html.matchAll(/["']([^"']*\/_og\/s\/o_[^"']+\.png)["']/g)) { |
| 129 | + refs.add(m[1]!) |
| 130 | + } |
| 131 | + // JSON-LD blocks — walk every string field looking for the same prefix. |
| 132 | + for (const m of html.matchAll( |
| 133 | + /<script[^>]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g, |
| 134 | + )) { |
| 135 | + let data: unknown |
| 136 | + try { |
| 137 | + data = JSON.parse(m[1]!) |
| 138 | + } catch { |
| 139 | + // Malformed JSON-LD is its own bug; not this test's job. |
| 140 | + continue |
| 141 | + } |
| 142 | + JSON.stringify(data, (_, v) => { |
| 143 | + if (typeof v === 'string' && v.includes('/_og/s/o_')) refs.add(v) |
| 144 | + return v |
| 145 | + }) |
| 146 | + } |
| 147 | + // Strip the absolute prefix (host + baseURL) so we can resolve against DIST. |
| 148 | + return [...refs].map((u) => |
| 149 | + u.replace(new RegExp(`^https?://[^/]+${base}`), ''), |
| 150 | + ) |
| 151 | +} |
| 152 | + |
| 153 | +const htmlFiles = await walkHtml(DIST) |
| 154 | +const orphanCases: Array<{ html: string; ref: string }> = [] |
| 155 | +for (const file of htmlFiles) { |
| 156 | + const html = await readFile(file, 'utf8') |
| 157 | + for (const ref of extractStaticOgRefs(html, base)) { |
| 158 | + orphanCases.push({ html: relative(DIST, file), ref }) |
| 159 | + } |
| 160 | +} |
| 161 | + |
| 162 | +describe('Built pages: every static OG image URL resolves to a file', () => { |
| 163 | + // Sanity: if this is empty, the test is silently passing because no OG hash |
| 164 | + // URLs exist anywhere in the dist (would mean an OG-image regression of its |
| 165 | + // own). We expect at least the per-post meta to surface one. |
| 166 | + it('finds at least one /_og/s/o_*.png reference to validate', () => { |
| 167 | + expect(orphanCases.length).toBeGreaterThan(0) |
| 168 | + }) |
| 169 | + |
| 170 | + it.each(orphanCases)('$html → $ref exists', ({ ref }) => { |
| 171 | + expect(existsSync(join(DIST, ref)), `${ref} not in dist/`).toBe(true) |
| 172 | + }) |
| 173 | +}) |
0 commit comments