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() } })