const { test } = require('node:test') const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const Module = require('node:module') const ts = require('typescript') const vue = require('vue') const { parse, compileTemplate } = require('vue/compiler-sfc') const root = path.resolve(__dirname, '..') const raw = file => '/admin/sys-file/oss/file?fileName=' + file const settle = async () => { await new Promise(setImmediate); await vue.nextTick() } const compile = source => ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText function runtime() { const realNow = Date.now, previousUni = global.uni const clock = { now: 1793232000000, offset: 0, requests: 0, offline: false, network: undefined } const downloads = [], downloadStatuses = [] Date.now = () => clock.now global.uni = { request(options) { clock.requests++ queueMicrotask(() => clock.offline ? options.fail(new Error('offline')) : options.success({ statusCode: 200, data: { data: { serverTime: String(clock.now + clock.offset), ttlSeconds: '1800' } } })) }, downloadFile(options) { downloads.push(options.url) queueMicrotask(() => options.success({ statusCode: downloadStatuses.shift() ?? 200, tempFilePath: 'local-file' })) return { abort() {} } }, addInterceptor() {}, onNetworkStatusChange(handler) { clock.network = handler }, previewImage(options) { return options }, } const filename = path.join(root, 'src/sheep/utils/file-access.ts') const corePath = path.join(path.dirname(filename), 'file-access-core.ts') const core = new Module(corePath, module); core.filename = corePath; core.paths = Module._nodeModulePaths(path.dirname(corePath)) core._compile(compile(fs.readFileSync(corePath, 'utf8')), corePath) const env = { VITE_FILE_ACCESS_APP_ID: root.endsWith('pj_mini_app_cloud') ? 'mini' : 'h5', VITE_FILE_ACCESS_KEY_ID: 'v1', VITE_FILE_ACCESS_KEY: JSON.parse(fs.readFileSync(path.join(root, 'test-vectors/file-access.json'), 'utf8'))[0].key, VITE_IMAGE_DEV_PATH: 'https://files.example', VITE_APP_DEV_SERVICE: 'https://files.example' } const loaded = new Module(filename, module); loaded.filename = filename; loaded.paths = core.paths loaded.require = name => name === './file-access-core' ? core.exports : name === './app' ? { isDevEnv: () => true } : core.require(name) loaded._compile(compile(fs.readFileSync(filename, 'utf8').replace(/import\.meta\.env/g, '(' + JSON.stringify(env) + ')')), filename) // Execute the actual filePath wrapper as used by the page templates. const filePath = path.join(path.dirname(filename), 'file.ts') const file = new Module(filePath, module); file.filename = filePath; file.paths = core.paths file.require = name => name === './file-access' ? loaded.exports : core.require(name) file._compile(compile(fs.readFileSync(filePath, 'utf8')), filePath) return { api: loaded.exports, file: file.exports, clock, downloads, downloadStatuses, close() { loaded.exports.stopFileAccess(); Date.now = realNow; global.uni = previousUni } } } function mountPage(env, relative) { const descriptor = parse(fs.readFileSync(path.join(root, relative), 'utf8')).descriptor const result = compileTemplate({ source: descriptor.template.content, filename: relative, id: 'file-refresh', compilerOptions: { isCustomElement: tag => ['view', 'image', 'swiper', 'swiper-item'].includes(tag) } }) assert.deepEqual(result.errors, []) const loaded = { exports: {} } new Function('module', 'exports', 'require', compile(result.code))(loaded, loaded.exports, require) const patches = [], container = { children: [] } 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 if (node.type === 'image' && key === 'src' && next && previous !== next) patches.push({ node, url: 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 context = { SERVICE_IMAGE: { blueBg: raw('background.png') }, filePath: env.file.filePath, pageHook: vue.reactive({ list: [{ id: 1, name: '食堂1', logoPic: raw('canteen1.png') }, { id: 2, name: '食堂2', logoPic: raw('canteen2.png') }], scroll() {} }), state: vue.reactive({ swiperList: [{ id: 1, imageUrl: raw('banner.png') }] }), appMenuListSafe: [{ icon: raw('menu.png'), title: '食堂' }], noticeListSafe: [], groupedNoticeList: [], shouldScroll: false, crudLayoutRef: vue.ref(), pageRef: vue.ref(), query() {}, toDetail() {}, doMenu() {}, doSwiper() {}, router: { jump() {} }, } const app = renderer.createApp({ setup: () => context, render: loaded.exports.render }) app.component('crud-layout', { props: ['bg'], setup: (props, { slots }) => () => vue.h('view', [ props.bg ? vue.h('image', { src: env.api.accessFileUrl(props.bg) }) : null, slots.default?.(), ]) }) for (const name of ['z-paging', 'tm-tag', 'empty']) app.component(name, { setup: (_, { slots }) => () => vue.h('view', slots.default?.()) }) env.api.installFileAccess(app) app.mount(container) return { patches, context, urls: () => [...new Set(patches.map(patch => patch.node))].map(node => node.props.src), close: () => app.unmount() } } for (const relative of ['src/pages/tabbar/canteen.vue', 'src/pages/root/dashboard.vue'].filter(file => fs.existsSync(path.join(root, file)))) { test(relative + ': initial calibration, foreground and network recovery do not assign a second image URL', async () => { const env = runtime(), page = mountPage(env, relative) try { const urls = page.urls(), count = page.patches.length assert.ok(count >= 2) env.clock.now += 2300 await env.api.refreshFileAccess(true); await settle() assert.deepEqual(page.urls(), urls) assert.equal(page.patches.length, count) env.clock.now += 61000 env.api.startFileAccess(); await settle() assert.deepEqual(page.urls(), urls) env.clock.now += 2300 env.clock.network({ isConnected: true }); await settle() assert.deepEqual(page.urls(), urls) assert.equal(page.patches.length, count) } finally { page.close(); env.close() } }) } test('concurrent failures renew only the failing file while other images keep their signatures', async () => { const env = runtime(), page = mountPage(env, 'src/pages/tabbar/canteen.vue') try { const previous = page.urls(), count = page.patches.length, failing = env.file.filePath(raw('canteen1.png')) env.clock.now += 1000 assert.deepEqual(await Promise.all([env.api.retryFileAccess(failing), env.api.retryFileAccess(failing)]), [true, true]) await settle() assert.equal(env.clock.requests, 1) assert.equal(page.patches.length, count + 1) for (const url of previous.filter(url => !url.includes('canteen1.png'))) assert.ok(page.urls().includes(url)) const renewed = env.api.fileUrl(raw('canteen1.png')) assert.notEqual(renewed, failing) assert.equal(await env.api.retryFileAccess(renewed), false) assert.equal(env.clock.requests, 1) } finally { page.close(); env.close() } }) test('an expired attachment download does not refresh unrelated page images', async () => { const env = runtime(), page = mountPage(env, 'src/pages/tabbar/canteen.vue') try { const previous = page.urls(), count = page.patches.length env.api.fileUrl(raw('report.pdf')); env.clock.now += 1000 env.downloadStatuses.push(403, 200) await new Promise((resolve, reject) => env.api.downloadFile({ url: raw('report.pdf'), success: resolve, fail: reject })) await settle() assert.equal(env.downloads.length, 2) assert.notEqual(env.downloads[0], env.downloads[1]) assert.deepEqual(page.urls(), previous) assert.equal(page.patches.length, count) } finally { page.close(); env.close() } }) test('large clock corrections, near expiry and changed business images still generate valid URLs', async () => { const env = runtime(), page = mountPage(env, 'src/pages/tabbar/canteen.vue') try { const previous = env.api.fileUrl(raw('canteen1.png')) env.clock.offset = 86400000 await env.api.refreshFileAccess(true); await settle() const corrected = env.api.fileUrl(raw('canteen1.png')) assert.notEqual(corrected, previous) assert.equal(Number(new URL(corrected).searchParams.get('ts')), (env.clock.now + env.clock.offset) / 1000) env.clock.now += 26 * 60000; env.api.fileAccessRevision.value++; await settle() assert.notEqual(env.api.fileUrl(raw('canteen1.png')), corrected) page.context.pageHook.list[0].logoPic = raw('replacement.png'); await settle() assert.ok(page.urls().some(url => url.includes('replacement.png'))) assert.equal(page.context.pageHook.list[0].logoPic, raw('replacement.png')) } finally { page.close(); env.close() } })