165 lines
8.4 KiB
JavaScript
165 lines
8.4 KiB
JavaScript
const { test } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const vue = require('vue')
|
|
const { parse, compileScript } = require('@vue/compiler-sfc')
|
|
const { runtime, compile, root } = require('./helpers/file-access-runtime.cjs')
|
|
|
|
const raw = file => '/admin/sys-file/oss/file?fileName=' + file
|
|
const settle = async () => { await new Promise(resolve => setImmediate(resolve)); await vue.nextTick() }
|
|
|
|
function mountImages(api) {
|
|
const descriptor = parse(fs.readFileSync(path.join(root, 'src/components/FileImage/index.vue'), 'utf8')).descriptor
|
|
const component = compileScript(descriptor, { id: 'file-image-regression', inlineTemplate: true }).content
|
|
const loaded = { exports: {} }
|
|
const loads = [], failures = []
|
|
// Element Plus restarts loading when its src prop changes. Record that boundary
|
|
// while executing the real wrapper's setup, template, events and Vue lifecycle.
|
|
const ElImage = vue.defineComponent({
|
|
props: ['src', 'previewSrcList'], emits: ['load', 'error', 'show', 'switch', 'close'],
|
|
setup(props, { emit }) {
|
|
vue.watch(() => props.src, value => loads.push(value), { immediate: true })
|
|
return () => vue.h('img', {
|
|
src: props.src, preview: props.previewSrcList,
|
|
onLoad: event => emit('load', event), onError: event => emit('error', event),
|
|
onShow: () => emit('show'), onSwitch: index => emit('switch', index),
|
|
})
|
|
},
|
|
})
|
|
new Function('module', 'exports', 'require', compile(component))(loaded, loaded.exports, id => {
|
|
if (id === 'element-plus') return { ElImage }
|
|
if (id === '/@/utils/file-access') return api
|
|
return require(id)
|
|
})
|
|
const renderer = vue.createRenderer({
|
|
createElement: type => ({ type, props: {}, children: [] }), createText: text => ({ text }), createComment: text => ({ text }),
|
|
setText: (node, text) => { node.text = text }, setElementText: (node, text) => { node.text = text },
|
|
parentNode: node => node.parent, nextSibling: node => node.parent?.children[node.parent.children.indexOf(node) + 1],
|
|
patchProp: (node, key, previous, next) => { node.props[key] = next },
|
|
insert(node, parent, anchor) {
|
|
if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1)
|
|
node.parent = parent
|
|
const index = anchor ? parent.children.indexOf(anchor) : -1
|
|
parent.children.splice(index < 0 ? parent.children.length : index, 0, node)
|
|
},
|
|
remove(node) { if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1) },
|
|
})
|
|
const sources = vue.ref([raw('good.jpg'), raw('other.jpg')])
|
|
const container = { children: [] }
|
|
const app = renderer.createApp({
|
|
setup() {
|
|
return () => vue.h('section', sources.value.map((source, index) => vue.h(loaded.exports.default, {
|
|
key: index, src: api.accessFileUrl(source), previewSrcList: api.fileUrls([source]),
|
|
onError: () => failures.push(index),
|
|
})))
|
|
},
|
|
})
|
|
app.mount(container)
|
|
return { loads, failures, sources, images: () => container.children[0].children, close: () => app.unmount() }
|
|
}
|
|
|
|
test('one failed image renews only itself, coalesces concurrent retries, and remains bounded', async () => {
|
|
const env = runtime(), { api, clock } = env
|
|
try {
|
|
const good = api.fileUrl(raw('good.jpg')), bad = api.fileUrl(raw('missing.jpg'))
|
|
clock.now += 1000
|
|
assert.deepEqual(await Promise.all([api.retryFileAccess(raw('missing.jpg')), api.retryFileAccess(raw('missing.jpg'))]), [true, true])
|
|
assert.equal(clock.requests, 1)
|
|
assert.equal(api.fileUrl(raw('good.jpg')), good)
|
|
assert.notEqual(api.fileUrl(raw('missing.jpg')), bad)
|
|
assert.equal(await api.retryFileAccess(raw('missing.jpg')), false)
|
|
assert.equal(clock.requests, 1)
|
|
const offline = api.fileUrl(raw('offline.jpg'))
|
|
clock.offline = true; clock.now += 1000
|
|
assert.equal(await api.retryFileAccess(raw('offline.jpg')), true)
|
|
assert.notEqual(api.fileUrl(raw('offline.jpg')), offline)
|
|
assert.equal(api.fileUrl(raw('good.jpg')), good)
|
|
} finally { env.close() }
|
|
})
|
|
|
|
test('owned anchors fetch through the app downloader, while external and already handled links keep their behaviour', () => {
|
|
const env = runtime(), calls = []
|
|
try {
|
|
const event = (href, prevented = false) => ({
|
|
defaultPrevented: prevented,
|
|
target: { closest: () => ({ getAttribute: name => name === 'href' ? href : '' }) },
|
|
preventDefault() { this.defaultPrevented = true }, stopImmediatePropagation() { this.stopped = true },
|
|
})
|
|
const source = raw(encodeURIComponent('附件 +&%.pdf')), signed = env.api.fileUrl(source)
|
|
const click = event(signed)
|
|
assert.equal(env.api.handleFileLinkClick(click, (...args) => calls.push(args)), true)
|
|
assert.equal(click.defaultPrevented, true); assert.equal(click.stopped, true)
|
|
assert.equal(calls.length, 1)
|
|
assert.equal(calls[0][2], '附件 +&%.pdf')
|
|
assert.doesNotMatch(calls[0][0], /[?&](?:sign|ts|kid|appId)=/)
|
|
for (const href of ['https://other.example/file.pdf', 'blob:local-document', '/home']) {
|
|
const outside = event(href)
|
|
assert.equal(env.api.handleFileLinkClick(outside, () => assert.fail('unrelated link intercepted')), false)
|
|
assert.equal(outside.defaultPrevented, false)
|
|
}
|
|
assert.equal(env.api.handleFileLinkClick(event(signed, true), () => assert.fail('already handled')), false)
|
|
} finally { env.close() }
|
|
})
|
|
|
|
test('loaded thumbnails never reload on minute checks, expiry, or foreground clock sync; previews receive fresh signatures', async () => {
|
|
const env = runtime(), page = mountImages(env.api)
|
|
try {
|
|
page.images().forEach(image => image.props.onLoad(new Event('load')))
|
|
const before = page.images().map(image => image.props.src)
|
|
for (let minute = 0; minute < 31; minute++) {
|
|
env.clock.now += 60000
|
|
env.api.fileAccessRevision.value++
|
|
await vue.nextTick()
|
|
assert.deepEqual(page.images().map(image => image.props.src), before)
|
|
}
|
|
await env.api.refreshFileAccess(true); await settle()
|
|
assert.equal(page.loads.length, 2)
|
|
assert.deepEqual(page.images().map(image => image.props.src), before)
|
|
page.images()[0].props.onShow(); await settle()
|
|
const preview = page.images()[0].props.preview[0]
|
|
assert.notEqual(preview, before[0])
|
|
assert.equal(preview, env.api.fileUrl(raw('good.jpg')))
|
|
assert.ok(Number(new URL(preview).searchParams.get('ts')) + 1800 > env.clock.now / 1000)
|
|
assert.equal(page.loads.length, 2)
|
|
assert.equal(page.sources.value[0], raw('good.jpg'))
|
|
env.clock.now += 31 * 60000; env.api.fileAccessRevision.value++; await settle()
|
|
assert.equal(page.images()[0].props.preview[0], preview)
|
|
page.images()[0].props.onSwitch(0); await settle()
|
|
assert.notEqual(page.images()[0].props.preview[0], preview)
|
|
} finally { page.close(); env.close() }
|
|
})
|
|
|
|
test('failed thumbnails retry once without resetting neighbours; a changed business image still loads', async () => {
|
|
const env = runtime(), page = mountImages(env.api)
|
|
try {
|
|
page.images()[1].props.onLoad(new Event('load'))
|
|
const neighbour = page.images()[1].props.src
|
|
env.clock.now += 1000
|
|
page.images()[0].props.onError(new Event('error')); await settle()
|
|
assert.equal(page.loads.length, 3)
|
|
assert.equal(page.images()[1].props.src, neighbour)
|
|
page.images()[0].props.onError(new Event('error')); await settle()
|
|
assert.equal(page.loads.length, 3)
|
|
assert.deepEqual(page.failures, [0])
|
|
env.api.fileAccessRevision.value++; await settle()
|
|
assert.equal(page.loads.length, 3)
|
|
page.sources.value[0] = raw('replacement.jpg'); await settle()
|
|
assert.equal(new URL(page.images()[0].props.src).searchParams.get('fileName'), 'replacement.jpg')
|
|
assert.equal(page.images()[1].props.src, neighbour)
|
|
} finally { page.close(); env.close() }
|
|
})
|
|
|
|
test('offscreen images keep a valid URL for their first lazy request without reloading an already visible image', async () => {
|
|
const env = runtime(), page = mountImages(env.api)
|
|
try {
|
|
page.images()[1].props.onLoad(new Event('load'))
|
|
const before = page.images().map(image => image.props.src)
|
|
env.clock.now += 31 * 60000; env.api.fileAccessRevision.value++; await settle()
|
|
assert.notEqual(page.images()[0].props.src, before[0])
|
|
assert.equal(new URL(page.images()[0].props.src).searchParams.get('ts'), String(env.clock.now / 1000))
|
|
assert.equal(page.images()[1].props.src, before[1])
|
|
assert.equal(env.clock.requests, 0)
|
|
} finally { page.close(); env.close() }
|
|
})
|