169 lines
6.8 KiB
TypeScript
169 lines
6.8 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
import { downloadImageSafely, ImagePreview } from '../src/renderer/src/workbuddy/images/ImagePreview'
|
|
import { MarkdownImage } from '../src/renderer/src/workbuddy/images/MarkdownImage'
|
|
import { PreviewIcon } from '../src/renderer/src/workbuddy/images/PreviewIcon'
|
|
import {
|
|
clampPreviewIndex,
|
|
isSafeImageUrl,
|
|
makeImagePreviewRequest,
|
|
resolveImageUrl,
|
|
requiresHostImageResolution,
|
|
stepPreviewIndex
|
|
} from '../src/renderer/src/workbuddy/images/image-url'
|
|
|
|
async function testSafeImageSchemesAndResolution(): Promise<void> {
|
|
for (const url of [
|
|
'data:image/png;base64,AA==',
|
|
'https://example.test/image.png',
|
|
'HTTP://example.test/image.png',
|
|
'blob:https://example.test/id',
|
|
'file:///C:/images/photo.png',
|
|
'local-file://image/photo.png'
|
|
]) assert.equal(isSafeImageUrl(url), true, `${url} should be safe`)
|
|
|
|
for (const url of [
|
|
'javascript:alert(1)',
|
|
'data:text/html,unsafe',
|
|
'../images/photo.png',
|
|
'not a URL'
|
|
]) assert.equal(isSafeImageUrl(url), false, `${url} should be rejected`)
|
|
|
|
let resolverCalls = 0
|
|
assert.equal(await resolveImageUrl('https://example.test/image.png', async () => {
|
|
resolverCalls++
|
|
return 'data:image/png;base64,AA=='
|
|
}), 'https://example.test/image.png')
|
|
assert.equal(resolverCalls, 0, 'safe sources should bypass host resolution')
|
|
assert.equal(await resolveImageUrl('C:\\workspace\\photo.png', async (source) => {
|
|
assert.equal(source, 'C:\\workspace\\photo.png')
|
|
return 'data:image/png;base64,BB=='
|
|
}), 'data:image/png;base64,BB==')
|
|
assert.equal(requiresHostImageResolution('file:///C:/workspace/photo.png'), true)
|
|
assert.equal(requiresHostImageResolution('local-file://workspace/photo.png'), true)
|
|
assert.equal(requiresHostImageResolution('https://example.test/photo.png'), false)
|
|
let hostCalls = 0
|
|
assert.equal(await resolveImageUrl('file:///C:/workspace/photo.png', async () => {
|
|
hostCalls++
|
|
return 'data:image/png;base64,CC=='
|
|
}, true), 'data:image/png;base64,CC==')
|
|
assert.equal(hostCalls, 1, 'preferred host resolution should run for local file schemes')
|
|
assert.equal(await resolveImageUrl('https://example.test/photo.png', async () => {
|
|
hostCalls++
|
|
return 'data:image/png;base64,DD=='
|
|
}, true), 'https://example.test/photo.png')
|
|
assert.equal(hostCalls, 1, 'preferred host resolution should not intercept remote images')
|
|
assert.equal(await resolveImageUrl('/unsafe/path.png', async () => 'javascript:alert(1)'), undefined)
|
|
assert.equal(await resolveImageUrl('/no-resolver.png'), undefined)
|
|
await assert.rejects(resolveImageUrl('/reject.png', async () => { throw new Error('resolver failed') }), /resolver failed/)
|
|
}
|
|
|
|
function testSafePreviewFilteringAndIndex(): void {
|
|
assert.deepEqual(makeImagePreviewRequest([
|
|
'https://example.test/one.png',
|
|
'../unsafe.png',
|
|
'data:image/png;base64,AA==',
|
|
'javascript:alert(1)'
|
|
], 'data:image/png;base64,AA=='), {
|
|
urlList: ['https://example.test/one.png', 'data:image/png;base64,AA=='],
|
|
initialIndex: 1
|
|
})
|
|
assert.deepEqual(makeImagePreviewRequest(['../one.png'], 'data:image/png;base64,AA=='), {
|
|
urlList: [],
|
|
initialIndex: 0
|
|
})
|
|
assert.equal(clampPreviewIndex(-3, 4), 0)
|
|
assert.equal(clampPreviewIndex(9, 4), 3)
|
|
assert.equal(clampPreviewIndex(1, 0), 0)
|
|
assert.equal(stepPreviewIndex(1, 4, -1), 0)
|
|
assert.equal(stepPreviewIndex(2, 4, 1), 3)
|
|
}
|
|
|
|
async function testRejectedImageDownloadIsReported(): Promise<void> {
|
|
let message = ''
|
|
await downloadImageSafely(async () => {
|
|
throw new Error('disk unavailable')
|
|
}, 'https://example.test/image.png', (value) => { message = value })
|
|
assert.equal(message, '下载失败:disk unavailable')
|
|
}
|
|
|
|
function testMarkdownImageServerFallback(): void {
|
|
const html = renderToStaticMarkup(
|
|
<MarkdownImage
|
|
src="C:\\workspace\\photo.png"
|
|
alt="source diagram"
|
|
urlList={['https://example.test/photo.png']}
|
|
imageUrlResolver={async () => 'data:image/png;base64,AA=='}
|
|
/>
|
|
)
|
|
assert.match(html, /class="[^"]*cr-image-block-fallback[^"]*"/)
|
|
assert.match(html, /aria-busy="true"/)
|
|
assert.match(html, /role="img"/)
|
|
assert.match(html, />source diagram<\/span>/)
|
|
|
|
const source = 'C:\\workspace\\source-only.png'
|
|
const sourceFallback = renderToStaticMarkup(<MarkdownImage src={source} urlList={[]} />)
|
|
assert.ok(!sourceFallback.includes(source), 'unresolved source is never fallback text')
|
|
assert.ok(sourceFallback.length < 500)
|
|
|
|
const synthetic = 'data:image/png;base64,' + 'A'.repeat(24000)
|
|
const first = renderToStaticMarkup(<MarkdownImage src={synthetic} alt="" urlList={[synthetic]} />)
|
|
assert.match(first, /<img /)
|
|
assert.ok(first.includes(`src="${synthetic}"`))
|
|
assert.ok(!first.includes('cr-image-block-fallback'))
|
|
assert.ok(!first.replace(/<[^>]*>/g, '').includes('data:image'))
|
|
for (const invalid of ['javascript:alert(1)', 'data:text/html,' + 'A'.repeat(24000)]) {
|
|
const markup = renderToStaticMarkup(<MarkdownImage src={invalid} alt="" urlList={[]} />)
|
|
assert.ok(markup.length < 500)
|
|
assert.ok(!markup.includes(invalid))
|
|
assert.ok(!markup.includes('<img '))
|
|
}
|
|
}
|
|
|
|
function testPreviewDialogServerStructure(): void {
|
|
const html = renderToStaticMarkup(
|
|
<ImagePreview
|
|
urlList={['https://example.test/one.png', 'https://example.test/two.png']}
|
|
initialIndex={1}
|
|
onClose={() => undefined}
|
|
onDownload={() => undefined}
|
|
/>
|
|
)
|
|
assert.match(html, /role="dialog"/)
|
|
assert.match(html, /aria-modal="true"/)
|
|
assert.match(html, /src="https:\/\/example\.test\/two\.png"/)
|
|
assert.match(html, /crossorigin="use-credentials"/)
|
|
assert.match(html, /aria-label="上一张"/)
|
|
assert.match(html, /aria-label="下一张"/)
|
|
assert.match(html, /aria-label="下载图片"/)
|
|
assert.match(html, /2\/2/)
|
|
}
|
|
|
|
function testRecoveredPreviewIconVectors(): void {
|
|
const actualSize = renderToStaticMarkup(<PreviewIcon name="actual-size" />)
|
|
assert.match(actualSize, /d="M2\.5 0L1\.25 0L1\.25 1L0 1L0 2\.25/)
|
|
|
|
const zoomIn = renderToStaticMarkup(<PreviewIcon name="zoom-in" />)
|
|
assert.match(zoomIn, /viewBox="0 0 16 16"/)
|
|
assert.match(zoomIn, /transform="matrix\(1 0 0 1 0\.882352 0\.882294\)"/)
|
|
|
|
const close = renderToStaticMarkup(<PreviewIcon name="close" size={20} />)
|
|
assert.match(close, /viewBox="0 0 12 12"/)
|
|
assert.match(close, /d="M9\.375 2\.625L2\.625 9\.375"/)
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
await testSafeImageSchemesAndResolution()
|
|
testSafePreviewFilteringAndIndex()
|
|
await testRejectedImageDownloadIsReported()
|
|
testMarkdownImageServerFallback()
|
|
testPreviewDialogServerStructure()
|
|
testRecoveredPreviewIconVectors()
|
|
console.log('image-renderer-regression: all checks passed')
|
|
}
|
|
|
|
void main().catch((error: unknown) => {
|
|
console.error('image-renderer-regression failed:', error)
|
|
process.exitCode = 1
|
|
})
|