diff --git a/temp-screenshots/root-relative-image/after-dark.png b/temp-screenshots/root-relative-image/after-dark.png
new file mode 100644
index 00000000000..b64fd3d24c7
Binary files /dev/null and b/temp-screenshots/root-relative-image/after-dark.png differ
diff --git a/temp-screenshots/root-relative-image/after-light.png b/temp-screenshots/root-relative-image/after-light.png
new file mode 100644
index 00000000000..b0ccdab928c
Binary files /dev/null and b/temp-screenshots/root-relative-image/after-light.png differ
diff --git a/temp-screenshots/root-relative-image/before-dark.png b/temp-screenshots/root-relative-image/before-dark.png
new file mode 100644
index 00000000000..6831f603f15
Binary files /dev/null and b/temp-screenshots/root-relative-image/before-dark.png differ
diff --git a/website/capture/root-relative-image.html b/website/capture/root-relative-image.html
new file mode 100644
index 00000000000..6eaa28c07e5
--- /dev/null
+++ b/website/capture/root-relative-image.html
@@ -0,0 +1,11 @@
+
+
+
+
+ Root-relative markdown image capture
+
+
+
+
+
+
diff --git a/website/capture/root-relative-image.tsx b/website/capture/root-relative-image.tsx
new file mode 100644
index 00000000000..03a40b1bbeb
--- /dev/null
+++ b/website/capture/root-relative-image.tsx
@@ -0,0 +1,61 @@
+/**
+ * Isolated capture entry for root-relative images in a previewed markdown file
+ * (issue #23623).
+ *
+ * The real MarkdownRenderer renders inside the same BasePathCtx the file
+ * preview (ContentRenderer) provides, for a file at `?file=`. The
loads
+ * hit /api/file-raw, which the shoot script answers from a real directory on
+ * disk: a file that exists is served, any other path 404s, the same contract as
+ * the backend endpoint. So which candidate loads is decided by the filesystem,
+ * not by the entry.
+ *
+ * Theme comes from the query string: ?theme=dark|light
+ */
+import { createRoot } from 'react-dom/client'
+
+import { initI18n } from '../src/i18n/all'
+import MarkdownRenderer, { BasePathCtx } from '../src/components/MarkdownRenderer'
+import '../src/index.css'
+
+const params = new URLSearchParams(location.search)
+const theme = params.get('theme') || 'dark'
+const file = params.get('file') || ''
+
+document.documentElement.setAttribute('data-theme', theme === 'light' ? 'kiro-light' : 'kiro-dark')
+
+// The broken-image chip's HEAD probe goes through fetch, which page.route does
+// not see for a same-origin dev-server request in every Chromium build; answer
+// it the way the endpoint would for a path that does not exist.
+const realFetch = globalThis.fetch.bind(globalThis)
+globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
+ if (url.startsWith('/api/file-raw') && init?.method === 'HEAD') {
+ return Promise.resolve(new Response(null, { status: 404 }))
+ }
+ return realFetch(input, init)
+}) as typeof globalThis.fetch
+
+const MD = `## Site-root image
+
+Written the way GitHub, Zenn and Docusaurus expect, from the root of the repository:
+
+
+
+## Image that is really missing
+
+
+`
+
+async function main() {
+ initI18n('en')
+ const root = createRoot(document.getElementById('root')!)
+ root.render(
+
+
+
+
+
,
+ )
+}
+
+void main()
diff --git a/website/capture/shoot-root-relative-image.mjs b/website/capture/shoot-root-relative-image.mjs
new file mode 100644
index 00000000000..d20f52e7f98
--- /dev/null
+++ b/website/capture/shoot-root-relative-image.mjs
@@ -0,0 +1,74 @@
+/**
+ * Screenshot harness for root-relative images in a previewed markdown file
+ * (issue #23623).
+ *
+ * Builds a throwaway repository on disk — `articles/post.md` plus
+ * `images/post/architecture.png` at the repository root, the Zenn/GitHub
+ * layout — then serves capture/root-relative-image.html from a programmatic
+ * Vite dev server on loopback. Every /api/file-raw request is answered from
+ * the real filesystem: an existing file is served, anything else is a 404,
+ * the same contract as the backend endpoint. Each requested path is printed,
+ * so the log shows the order the candidates were tried in.
+ *
+ * Usage (from website/): node capture/shoot-root-relative-image.mjs [outDir] [prefix]
+ */
+import { chromium } from 'playwright'
+import { createServer } from 'vite'
+import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { fileURLToPath } from 'node:url'
+import { dirname, join, resolve } from 'node:path'
+import { chromiumExecutable } from '../scripts/lib/chromium-executable.mjs'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const OUT = process.argv[2] || resolve(here, '../../temp-screenshots/root-relative-image')
+const PREFIX = process.argv[3] || 'after'
+mkdirSync(OUT, { recursive: true })
+
+const repo = mkdtempSync(join(tmpdir(), 'root-relative-image-'))
+mkdirSync(join(repo, 'articles'), { recursive: true })
+mkdirSync(join(repo, 'images/post'), { recursive: true })
+writeFileSync(join(repo, 'articles/post.md'), '\n')
+copyFileSync(resolve(here, '../public/icon-512.png'), join(repo, 'images/post/architecture.png'))
+const file = join(repo, 'articles/post.md')
+
+const server = await createServer({
+ configFile: './vite.config.ts',
+ server: { host: '127.0.0.1', port: 5199, strictPort: false },
+ logLevel: 'warn',
+})
+await server.listen()
+const base = server.resolvedUrls?.local?.[0]?.replace(/\/$/, '') || `http://127.0.0.1:${server.config.server.port}`
+
+const browser = await chromium.launch({
+ executablePath: chromiumExecutable(),
+ env: { ...process.env, LD_LIBRARY_PATH: '' },
+})
+const page = await browser.newPage({ viewport: { width: 760, height: 900 }, deviceScaleFactor: 2 })
+page.on('pageerror', e => console.log('PAGE EXCEPTION:', e.message))
+await page.route('**/api/file-raw*', route => {
+ const path = new URL(route.request().url()).searchParams.get('path') || ''
+ const hit = existsSync(path) && statSync(path).isFile()
+ console.log(`${hit ? '200' : '404'} ${path}`)
+ return hit
+ ? route.fulfill({ status: 200, contentType: 'image/png', body: readFileSync(path) })
+ : route.fulfill({ status: 404, contentType: 'application/json', body: '{"error":"not found"}' })
+})
+
+for (const theme of ['dark', 'light']) {
+ await page.goto(`${base}/capture/root-relative-image.html?theme=${theme}&file=${encodeURIComponent(file)}`)
+ // Settled when the site-root image has loaded (or been replaced by the chip)
+ // and the missing one shows its chip.
+ await page.waitForFunction(() => {
+ const imgs = [...document.querySelectorAll('img')]
+ const pending = imgs.some(i => !i.complete)
+ return !pending && document.body.textContent?.includes('Screenshot that was never committed')
+ && document.body.textContent?.match(/Image (file no longer exists|failed to load)/g)
+ })
+ const shot = join(OUT, `${PREFIX}-${theme}.png`)
+ await page.locator('#root > div').screenshot({ path: shot })
+ console.log('wrote', shot)
+}
+
+await browser.close()
+await server.close()
diff --git a/website/docs/frontend-conventions.md b/website/docs/frontend-conventions.md
index 3b0e944a8c6..eac15ab5faf 100644
--- a/website/docs/frontend-conventions.md
+++ b/website/docs/frontend-conventions.md
@@ -601,6 +601,25 @@ payloads. Widening that regex or its key scope is a security change — the same
constant also decides which paths are treated as local file reads, so the two
decisions must stay on the one exported copy in `urlTransform.ts`.
+A root-relative image (`/images/a.png`) inside a previewed file (a
+`BasePathCtx` is set) is tried as an absolute path first, then against each
+directory above the file, nearest first, up to eight levels
+(`siteRootImageCandidates`). The leading `/` there usually names the root of the
+repository or site the markdown belongs to — GitHub, Zenn and Docusaurus all
+write images that way (issue #23623) — and nothing in the file says where that
+root is. A same-named image in a nearer directory therefore wins over one at
+the real root. Every candidate still goes through `/api/file-raw`, so the fallback
+reads nothing the absolute path could not already ask for. When every candidate
+fails, the broken-image fallback names and probes the path as written, as it
+does for any other image, so its "no longer exists" wording speaks for that path
+alone. That fallback states the failure through an inline `ErrorNotice` beside
+a chip that copies the on-disk path. The notice offers no agent hand-off: the
+renderer also previews unsaved drafts (the meeting note's Preview), and the
+navigation would unmount one. Every surface that
+provides `BasePathCtx` gets this (the file preview, the file explorer, the
+meeting notes sidebar); outside them (chat, artifacts) a leading-slash path
+stays absolute only.
+
To SHOW a gateway path's file name, use `pathBasename` (and
`stripTrailingSeparators` for a directory) from `utils/pathBasename.ts`, never
`.split('/').pop()`: a Windows gateway reports `C:\repo\a.ts`, which a `/`-only
diff --git a/website/src/components/markdown/ImgWithFallback.tsx b/website/src/components/markdown/ImgWithFallback.tsx
index 1e854e8c44a..4ce96c7302a 100644
--- a/website/src/components/markdown/ImgWithFallback.tsx
+++ b/website/src/components/markdown/ImgWithFallback.tsx
@@ -4,6 +4,7 @@ import { Check, Copy, Image as ImageIcon, ImageOff } from 'lucide-react'
import { getImageDims, rememberImageDims } from '../../utils/imageDims'
import { WINDOWS_ABS_PATH_RE, decodeLocalPath } from '../../utils/urlTransform'
import { i18nT } from '../../i18n/t'
+import ErrorNotice from '../ErrorNotice'
import { BasePathCtx, CompactImagesCtx, ImageVersionCtx, MdSourceCtx } from './contexts'
import { useTitleCuedCopy } from './copyFeedback'
import { basenameOf } from './pathReferences'
@@ -27,10 +28,10 @@ import { dispatchLightbox } from './Lightbox'
* Chat images are read from disk at VIEW time (`/api/file-raw`), not stored in
* the message — so the dominant failure is a local file that no longer exists
* (a screenshot written to a temp directory that has since been cleaned), long
- * after the message rendered fine for its author. The chip names that
- * condition, and the whole chip is click-to-copy for the on-disk path:
- * recovery starts from knowing WHICH file is gone, and the path is the one
- * thing the transcript still holds.
+ * after the message rendered fine for its author. The failure is stated by an
+ * inline `ErrorNotice`, and beside it a chip names
+ * the image and copies its on-disk path: recovery starts from knowing WHICH
+ * file is gone, and the path is the one thing the transcript still holds.
*
* The `
` error event carries no status, so "file no longer exists" is
* NOT asserted from the error alone — a backend hiccup, a sensitive-path
@@ -71,26 +72,37 @@ function BrokenImage({ path, alt, probeUrl }: { path: string; alt?: string; prob
const idle = alt
? `${path}\n${i18nT('components.markdownRenderer.click_to_copy')}`
: i18nT('components.markdownRenderer.click_to_copy')
+ const failure = confirmedGone
+ ? i18nT('components.markdownRenderer.image_file_no_longer_exists')
+ : i18nT('components.markdownRenderer.image_failed_to_load')
+ // The load failure renders through ErrorNotice like every other failure
+ // (errors-use-error-notice); the chip beside it keeps the one recovery the
+ // transcript itself can offer, copying the on-disk path.
return (
<>
- { if (e.key === 'Enter' || e.key === ' ') handleCopy(e) }}
- title={copied ? i18nT('components.markdownRenderer.copied') : idle}
- >
-
- {alt || path}
-
- {confirmedGone
- ? i18nT('components.markdownRenderer.image_file_no_longer_exists')
- : i18nT('components.markdownRenderer.image_failed_to_load')}
+
+ { if (e.key === 'Enter' || e.key === ' ') handleCopy(e) }}
+ title={copied ? i18nT('components.markdownRenderer.copied') : idle}
+ data-testid="md-broken-image-chip"
+ >
+
+ {alt || path}
+ {copied
+ ?
+ : }
- {copied
- ?
- : }
+ {/* No hand-off: this fallback renders in every MarkdownRenderer host,
+ including the meeting note's Preview (NoteSidebar), where the note
+ draft lives only in the view the hand-off would unmount, and which
+ withholds the hand-off on its own notice for that reason. A shared
+ renderer cannot tell which host holds a draft, so it never offers
+ the navigation. */}
+
{failureBubble}
>
@@ -134,6 +146,48 @@ export function pendingImageBoxStyle(compact: boolean): React.CSSProperties {
return compact ? { width: '240px', height: '180px' } : { width: '420px', height: '236px' }
}
+/** How many directories above a previewed file a root-relative image is tried
+ * against. Covers a site or repository root several levels up while bounding
+ * the 404s a genuinely missing image costs. */
+const MAX_SITE_ROOT_ANCESTORS = 8
+
+/** Fallback on-disk paths for a root-relative image (`/images/a.png`) in a
+ * previewed file, nearest directory first.
+ *
+ * A leading `/` in a markdown image usually means "the root of the repository
+ * or site this file belongs to", not the root of the disk: GitHub resolves it
+ * against the repository root in a README, and Zenn, Docusaurus and similar
+ * setups write images the same way (issue #23623). Nothing in the file says
+ * where that root is, so the directories above the file are the candidates,
+ * starting with the file's own. On POSIX the filesystem root is left out
+ * because that candidate IS the absolute path, which the caller tries first.
+ * A Windows drive root is kept (`D:/images/a.png`): the caller's absolute
+ * reading resolves against the gateway's own drive, which need not be the
+ * previewed file's.
+ *
+ * `\` counts as a separator only when the file itself sits on a drive-rooted
+ * Windows path; on POSIX a backslash is a legal file-name character. */
+export function siteRootImageCandidates(filePath: string, rootRelative: string): string[] {
+ const rel = rootRelative.replace(/^\/+/, '')
+ if (!rel) return []
+ const parentOf = WINDOWS_ABS_PATH_RE.test(filePath)
+ ? (p: string) => p.replace(/[\\/][^\\/]*$/, '')
+ : (p: string) => p.replace(/\/[^/]*$/, '')
+ let dir = parentOf(filePath)
+ // No separator at all: there is no directory to walk from.
+ if (dir === filePath) return []
+ const out: string[] = []
+ while (dir && out.length < MAX_SITE_ROOT_ANCESTORS) {
+ out.push(`${dir}/${rel}`)
+ // A drive (`D:`) is the top of a Windows path: nothing above it.
+ if (/^[A-Za-z]:$/.test(dir)) break
+ const next = parentOf(dir)
+ if (next === dir) break
+ dir = next
+ }
+ return out
+}
+
export function ImgWithFallback({
node,
src,
@@ -146,6 +200,10 @@ export function ImgWithFallback({
// Per-src like the outcome flags: a reused instance handed a different src
// must not inherit the previous image's approval.
const [remoteApproved, setRemoteApproved] = useState(false)
+ // Which on-disk candidate is loading: 0 is the path as written; a
+ // root-relative path in a previewed file advances through
+ // `siteRootImageCandidates` on each load error.
+ const [attempt, setAttempt] = useState(0)
const approvedImageRef = useApprovalFocus(remoteApproved)
// Both flags describe the outcome of loading THIS `src`, so neither may
// outlive it. React reuses an instance whenever the element at a key keeps its
@@ -161,8 +219,20 @@ export function ImgWithFallback({
setErrored(false)
setLoaded(false)
setRemoteApproved(false)
+ setAttempt(0)
}
const basePath = useContext(BasePathCtx)
+ // The candidate list is derived from the previewed file too, so neither an
+ // index into one file's list nor that list's outcome may carry over to
+ // another file's: an unkeyed FileViewer reuses this instance when the user
+ // switches tabs, and a stale `errored` would never try the new file's list.
+ // Keyed on the candidate PATHS, not on `basePath`: two files in one folder
+ // naming the same image resolve to the same list, and resetting then would
+ // drop `loaded` for an `
` React leaves untouched, which fires no
+ // new `load` event and leaves the skeleton over a loaded image. The image
+ // version is not part of the key, so a rewritten file keeps the behaviour
+ // it had before this list existed.
+ const [attemptKey, setAttemptKey] = useState(null)
const compact = useContext(CompactImagesCtx)
const version = useContext(ImageVersionCtx)
const source = useContext(MdSourceCtx)
@@ -182,6 +252,10 @@ export function ImgWithFallback({
// The on-disk path the backend is asked to read — what the broken-image
// fallback discloses and copies. Stays `src` verbatim for remote URLs.
let diskPath = src
+ // Every /api/file-raw URL this image may try, in order; more than one only
+ // for a root-relative path in a previewed file. The broken-image chip probes
+ // all of them.
+ let candidateUrls: string[] = []
if (isLocal) {
// micromark percent-encodes destinations in BOTH forms, so wrap-ness is
// recovered from the source text at this node's position: only a
@@ -196,18 +270,44 @@ export function ImgWithFallback({
const wrapped = source != null && start != null && end != null
&& /\]\(\s* `/api/file-raw?path=${encodeURIComponent(p)}${version ? `&v=${encodeURIComponent(version)}` : ''}`
+ let candidatePaths: string[]
if (basePath && !src.startsWith('/') && !src.startsWith('~') && !isWinAbs) {
const resolved = basePath.replace(/\/[^/]*$/, '') + '/' + localPath
diskPath = resolved
- url = `/api/file-raw?path=${encodeURIComponent(resolved)}`
+ candidatePaths = [resolved]
} else {
diskPath = localPath
- url = `/api/file-raw?path=${encodeURIComponent(localPath)}`
+ // A root-relative path in a previewed file: the absolute reading goes
+ // first, so a real absolute path still loads on the first request, then
+ // the directories above the file (issue #23623). `//` is a protocol-
+ // relative URL or UNC share, never a site root. The chip keeps naming
+ // the path as written, which is what the author can act on.
+ candidatePaths = basePath && src.startsWith('/') && !src.startsWith('//')
+ ? [localPath, ...siteRootImageCandidates(basePath, localPath)]
+ : [localPath]
}
- // See ImageVersionCtx: without this every impression of a rewritten file
- // shares one cache entry and a new message renders the previous bytes. The
- // backend reads only `path`, so the extra parameter is inert server-side.
- if (version) url += `&v=${encodeURIComponent(version)}`
+ candidateUrls = candidatePaths.map(rawUrl)
+ let effectiveAttempt = attempt
+ const candidateKey = candidatePaths.join('\n')
+ if (attemptKey !== candidateKey) {
+ // First render, or the list changed (another previewed file). The path
+ // on screen only changes when the new list starts somewhere else, and
+ // only then can the old `loaded` be wrong.
+ if (attemptKey !== null) {
+ const previous = attemptKey.split('\n')
+ const shownPath = previous[Math.min(attempt, previous.length - 1)]
+ if (shownPath !== candidatePaths[0]) setLoaded(false)
+ setAttempt(0)
+ setErrored(false)
+ effectiveAttempt = 0
+ }
+ setAttemptKey(candidateKey)
+ }
+ url = candidateUrls[Math.min(effectiveAttempt, candidateUrls.length - 1)]
} else {
url = src
}
@@ -229,7 +329,11 @@ export function ImgWithFallback({
)
}
if (errored) {
- return
+ // The chip names the path as written, so it probes that path: the URL main
+ // probed for every image. A root-relative image's fallbacks are not
+ // probed, which keeps a refused (403) as-written file out of the
+ // missing-file wording however its fallbacks answer.
+ return
}
// SVGs authored with only a `viewBox` (no width/height) carry no intrinsic
// size. Under the max-w/max-h-only CSS below they collapse to ~0px and look
@@ -352,7 +456,10 @@ export function ImgWithFallback({
if (el.naturalWidth > 0 && el.naturalHeight > 0) rememberImageDims(url, el.naturalWidth, el.naturalHeight)
setLoaded(true)
}}
- onError={() => setErrored(true)}
+ onError={() => {
+ if (attempt + 1 < candidateUrls.length) setAttempt(attempt + 1)
+ else setErrored(true)
+ }}
{...props}
/>
{/* Provenance survives the click. Once loaded, a remote image is pixel
diff --git a/website/src/test/MarkdownRenderer.brokenImage.test.tsx b/website/src/test/MarkdownRenderer.brokenImage.test.tsx
index 560e581f967..2e16a70f26e 100644
--- a/website/src/test/MarkdownRenderer.brokenImage.test.tsx
+++ b/website/src/test/MarkdownRenderer.brokenImage.test.tsx
@@ -24,6 +24,7 @@ vi.mock('../utils/clipboard', () => ({
const GONE = '/tmp/aws-control-tour/01-accounts.png'
const MISSING_LABEL = 'Image file no longer exists'
const GENERIC_LABEL = 'Image failed to load'
+const CHIP = 'md-broken-image-chip'
const fetchMock = vi.fn()
@@ -49,7 +50,7 @@ function renderErrored(content: string) {
describe('broken-image fallback chip', () => {
it('names the missing-file condition only after the probe confirms 404', async () => {
- const { getByRole, getByText, findByText } = renderErrored(``)
+ const { getByTestId, getByText, findByText } = renderErrored(``)
getByText('账户列表')
await findByText(MISSING_LABEL)
// The probe re-asked the same endpoint the
failed on.
@@ -59,10 +60,23 @@ describe('broken-image fallback chip', () => {
)
// The chip is an interactive copy affordance, not a mute span; its tooltip
// leads with the on-disk path so a truncated chip discloses the target.
- const chip = getByRole('button')
+ const chip = getByTestId(CHIP)
expect(chip.getAttribute('title')).toContain(GONE)
})
+ it('states the load failure through the shared ErrorNotice, without a hand-off that could unmount a draft', async () => {
+ const { getByTestId, findByText, queryByRole } = renderErrored(``)
+ await findByText(MISSING_LABEL)
+ const notice = getByTestId('md-broken-image-error')
+ expect(notice.getAttribute('role')).toBe('alert')
+ expect(notice).toHaveTextContent(MISSING_LABEL)
+ // The wording lives in the notice, not in the copy chip.
+ expect(getByTestId(CHIP)).not.toHaveTextContent(MISSING_LABEL)
+ // This renderer also previews unsaved drafts (the meeting note), so the
+ // fallback never offers the navigation that would unmount one.
+ expect(queryByRole('button', { name: /ask the agent/i })).toBeNull()
+ })
+
it('keeps the generic wording when the probe answers 403 (denied is not deleted)', async () => {
fetchMock.mockResolvedValue({ status: 403 })
const { getByText, queryByText } = renderErrored(``)
@@ -81,8 +95,8 @@ describe('broken-image fallback chip', () => {
it('copies the on-disk path on click and confirms via the title flash once the write lands', async () => {
vi.mocked(copyToClipboard).mockResolvedValue(true)
- const { getByRole } = renderErrored(``)
- const chip = getByRole('button')
+ const { getByTestId } = renderErrored(``)
+ const chip = getByTestId(CHIP)
await act(async () => { fireEvent.click(chip) })
expect(copyToClipboard).toHaveBeenCalledWith(GONE)
expect(chip.getAttribute('title')).toBe('Copied!')
@@ -94,7 +108,7 @@ describe('broken-image fallback chip', () => {
// sentence around the chip is the refused copy.
fetchMock.mockReturnValue(new Promise(() => {}))
vi.mocked(copyToClipboard).mockResolvedValue(false)
- const { getByRole, getAllByTestId, getAllByRole, queryByRole, queryByTestId, container } = renderErrored(`Missing:  here.`)
+ const { getByRole, getByTestId, getAllByTestId, getAllByRole, queryByRole, queryByTestId, container } = renderErrored(`Missing:  here.`)
const chip = getByRole('button', { name: /账户列表/ })
const flow = container.querySelector('p')!
const flowBefore = flow.innerHTML
@@ -109,9 +123,10 @@ describe('broken-image fallback chip', () => {
expect(notices[0]).not.toHaveTextContent(/select the text/i)
expect(getByRole('tooltip').contains(notices[0])).toBe(true)
// Nothing beside the chip: the sentence around it is byte-for-byte what
- // it was, and the notice is the document's only (accessible) alert.
+ // it was. The copy notice is the only alert besides the image's own
+ // load-failure notice, which was already there before the click.
expect(flow.innerHTML).toBe(flowBefore)
- expect(getAllByRole('alert')).toEqual([notices[0]])
+ expect(getAllByRole('alert')).toEqual([getByTestId('md-broken-image-error'), notices[0]])
expect(queryByRole('button', { name: 'Dismiss' })).toBeNull()
act(() => { vi.advanceTimersByTime(COPY_FAILED_FLASH_MS) })
expect(queryByTestId('md-chip-copy-error')).toBeNull()
@@ -119,8 +134,8 @@ describe('broken-image fallback chip', () => {
})
it('copies via keyboard activation (Enter)', () => {
- const { getByRole } = renderErrored(``)
- const chip = getByRole('button')
+ const { getByTestId } = renderErrored(``)
+ const chip = getByTestId(CHIP)
expect(chip.getAttribute('tabindex')).toBe('0')
fireEvent.keyDown(chip, { key: 'Enter' })
expect(copyToClipboard).toHaveBeenCalledWith(GONE)
@@ -134,16 +149,16 @@ describe('broken-image fallback chip', () => {
})
it('falls back to the path as the visible label without repeating it in the tooltip', () => {
- const { getByText, getByRole } = renderErrored(``)
+ const { getByText, getByTestId } = renderErrored(``)
getByText(GONE)
// Label already shows the path — the tooltip must not restate it.
- expect(getByRole('button').getAttribute('title')).toBe('Click to copy')
+ expect(getByTestId(CHIP).getAttribute('title')).toBe('Click to copy')
})
it('decodes a producer-emitted angle-bracket destination back to the on-disk path', () => {
const spaced = '/tmp/shots/screen one.png'
- const { getByRole } = renderErrored('![shot]()')
- fireEvent.click(getByRole('button'))
+ const { getByTestId } = renderErrored('![shot]()')
+ fireEvent.click(getByTestId(CHIP))
expect(copyToClipboard).toHaveBeenCalledWith(spaced)
})
})
diff --git a/website/src/test/MarkdownRenderer.rootRelativeImage.test.tsx b/website/src/test/MarkdownRenderer.rootRelativeImage.test.tsx
new file mode 100644
index 00000000000..12a3397bfeb
--- /dev/null
+++ b/website/src/test/MarkdownRenderer.rootRelativeImage.test.tsx
@@ -0,0 +1,182 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, fireEvent, waitFor, act } from '@testing-library/react'
+import MarkdownRenderer, { BasePathCtx } from '../components/MarkdownRenderer'
+import { siteRootImageCandidates } from '../components/markdown/ImgWithFallback'
+
+/**
+ * A root-relative image (`/images/a.png`) in a previewed markdown file means
+ * "the root of the repository or site this file belongs to" — GitHub, Zenn and
+ * Docusaurus all write images that way — not the root of the disk. The preview
+ * used to send it to /api/file-raw verbatim, so every such image rendered as
+ * "Image file no longer exists" although the file sat next to the markdown
+ * (issue #23623). The absolute reading still goes first, then the directories
+ * above the file, nearest first.
+ */
+
+const raw = (p: string) => `/api/file-raw?path=${encodeURIComponent(p)}`
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404 }))
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+function renderPreview(content: string, filePath: string | null) {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('siteRootImageCandidates', () => {
+ it('walks the directories above the file, nearest first, without the disk root', () => {
+ expect(siteRootImageCandidates('/repo/zenn/articles/post.md', '/images/a.png')).toEqual([
+ '/repo/zenn/articles/images/a.png',
+ '/repo/zenn/images/a.png',
+ '/repo/images/a.png',
+ ])
+ })
+
+ it('ends at the file’s own drive root on Windows and accepts either separator there', () => {
+ // The drive root is kept: the absolute reading tried first resolves on
+ // the gateway's drive, which need not be the file's.
+ expect(siteRootImageCandidates('D:\\site\\docs\\post.md', '/img/a.png')).toEqual([
+ 'D:\\site\\docs/img/a.png',
+ 'D:\\site/img/a.png',
+ 'D:/img/a.png',
+ ])
+ })
+
+ it('keeps a backslash as a file-name character on POSIX', () => {
+ expect(siteRootImageCandidates('/repo/a\\b/post.md', '/x.png')).toEqual([
+ '/repo/a\\b/x.png',
+ '/repo/x.png',
+ ])
+ })
+
+ it('caps how far up it walks', () => {
+ const deep = '/' + Array.from({ length: 20 }, (_, i) => `d${i}`).join('/') + '/post.md'
+ expect(siteRootImageCandidates(deep, '/a.png')).toHaveLength(8)
+ })
+
+ it('returns nothing without a directory or an image path', () => {
+ expect(siteRootImageCandidates('post.md', '/a.png')).toEqual([])
+ expect(siteRootImageCandidates('/repo/post.md', '/')).toEqual([])
+ })
+})
+
+describe('root-relative image in a previewed file', () => {
+ it('tries the absolute path, then each directory above the file, until one loads', () => {
+ const { container } = renderPreview('', '/repo/articles/post.md')
+ const img = () => container.querySelector('img')!
+ expect(img().getAttribute('src')).toBe(raw('/images/post/shot.png'))
+ fireEvent.error(img())
+ expect(img().getAttribute('src')).toBe(raw('/repo/articles/images/post/shot.png'))
+ fireEvent.error(img())
+ expect(img().getAttribute('src')).toBe(raw('/repo/images/post/shot.png'))
+ fireEvent.load(img())
+ expect(container.textContent).not.toContain('Image file no longer exists')
+ expect(container.textContent).not.toContain('Image failed to load')
+ })
+
+ it('shows the broken-image chip, naming the path as written, once every candidate fails', async () => {
+ const { container, findByText } = renderPreview('', '/repo/post.md')
+ fireEvent.error(container.querySelector('img')!) // /images/gone.png
+ fireEvent.error(container.querySelector('img')!) // /repo/images/gone.png
+ expect(container.querySelector('img')).toBeNull()
+ await findByText('/images/gone.png')
+ })
+
+ it('says the file is gone when the path as written answers 404, probing only that path', async () => {
+ const { container, findByText } = renderPreview('', '/repo/post.md')
+ fireEvent.error(container.querySelector('img')!)
+ fireEvent.error(container.querySelector('img')!)
+ await findByText('Image file no longer exists')
+ const fetchMock = vi.mocked(globalThis.fetch)
+ expect(fetchMock.mock.calls.map(c => c[0])).toEqual([raw('/images/gone.png')])
+ })
+
+ it('keeps the generic wording when the as-written file is refused (403) and the fallbacks 404', async () => {
+ const probes: Promise[] = []
+ vi.mocked(globalThis.fetch).mockImplementation((input) => {
+ const p = Promise.resolve({ status: String(input) === raw('/home/u/photo.avif') ? 403 : 404 } as Response)
+ probes.push(p)
+ return p
+ })
+ const { container, findByText, queryByText } = renderPreview('', '/repo/post.md')
+ fireEvent.error(container.querySelector('img')!) // /home/u/photo.avif (403)
+ fireEvent.error(container.querySelector('img')!) // /repo/home/u/photo.avif (404)
+ await findByText('Image failed to load')
+ await waitFor(() => expect(probes).toHaveLength(1))
+ // Settle the probes the chip started, so its verdict has been applied.
+ await act(async () => { await Promise.all(probes) })
+ expect(queryByText('Image file no longer exists')).toBeNull()
+ expect(queryByText('Image failed to load')).not.toBeNull()
+ })
+
+ it('leaves a root-relative image outside a file preview on the absolute path alone', async () => {
+ const { container, findByText } = renderPreview('', null)
+ expect(container.querySelector('img')!.getAttribute('src')).toBe(raw('/tmp/shot.png'))
+ fireEvent.error(container.querySelector('img')!)
+ expect(container.querySelector('img')).toBeNull()
+ await findByText('/tmp/shot.png')
+ })
+
+ it('starts over from the absolute path when the image source changes', () => {
+ const { container, rerender } = renderPreview('', '/repo/post.md')
+ fireEvent.error(container.querySelector('img')!)
+ expect(container.querySelector('img')!.getAttribute('src')).toBe(raw('/repo/images/a.png'))
+ rerender(
+
+
+ ,
+ )
+ expect(container.querySelector('img')!.getAttribute('src')).toBe(raw('/images/b.png'))
+ })
+
+ it('starts over from the absolute path when the previewed file changes', () => {
+ const { container, rerender } = renderPreview('', '/repo/one/post.md')
+ fireEvent.error(container.querySelector('img')!)
+ expect(container.querySelector('img')!.getAttribute('src')).toBe(raw('/repo/one/images/a.png'))
+ rerender(
+
+
+ ,
+ )
+ expect(container.querySelector('img')!.getAttribute('src')).toBe(raw('/images/a.png'))
+ })
+
+ it('keeps a loaded image visible when another file in the same folder shows the same image', () => {
+ const skeleton = (c: HTMLElement) => c.querySelector('.animate-pulse')
+ const { container, rerender } = renderPreview('', '/repo/docs/a.md')
+ fireEvent.load(container.querySelector('img')!)
+ expect(skeleton(container)).toBeNull()
+ rerender(
+
+
+ ,
+ )
+ // Same resolved src, so the browser fires no new load event: the image must
+ // not fall back under the loading skeleton.
+ expect(container.querySelector('img')!.getAttribute('src')).toBe(raw('/repo/docs/images/logo.png'))
+ expect(skeleton(container)).toBeNull()
+ })
+
+ it('tries the new file’s candidates after every candidate failed for the previous file', () => {
+ const { container, rerender } = renderPreview('', '/one/post.md')
+ fireEvent.error(container.querySelector('img')!) // /images/a.png
+ fireEvent.error(container.querySelector('img')!) // /one/images/a.png
+ expect(container.querySelector('img')).toBeNull()
+ rerender(
+
+
+ ,
+ )
+ expect(container.querySelector('img')!.getAttribute('src')).toBe(raw('/images/a.png'))
+ fireEvent.error(container.querySelector('img')!)
+ expect(container.querySelector('img')!.getAttribute('src')).toBe(raw('/two/images/a.png'))
+ })
+})