164 lines
9.4 KiB
JavaScript
164 lines
9.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 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() }
|
|
})
|