import assert from "node:assert/strict" import { readFileSync } from "node:fs" import { createRequire, Module } from "node:module" import { runInNewContext } from "node:vm" import ts from "typescript" import * as vue from "vue" import { compileScript, parse } from "vue/compiler-sfc" const require = createRequire(import.meta.url) interface Version { id: number content: string version_no: number } function setupComponent(component: string, modules: Record, confirm: () => Promise = async () => {}) { const filename = new URL(`../src/pages/${component}`, import.meta.url) const { descriptor } = parse(readFileSync(filename, "utf8")) const script = compileScript(descriptor, { id: component }).content const code = ts.transpileModule(script, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText const globals = { ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch, onMounted() {}, onBeforeUnmount() {}, onBeforeRouteLeave() {}, setInterval() {}, ElMessage: { success() {}, warning() {} }, ElMessageBox: { confirm } } const mod = new Module(filename.pathname) const load = (path: string) => path in modules ? modules[path] : require(path) runInNewContext(code, { require: load, module: mod, exports: mod.exports, ...globals }, { filename: component }) return mod.exports.default.setup({}, { expose() {} }) } function skills(api: Record, confirm?: () => Promise) { return setupComponent("skills/index.vue", { "@/apis/skills": api, "@/components/SkillTree.vue": {}, "@/components/SkillVersionDrawer.vue": {}, "@/utils/markdown": { renderMarkdown: (text: string) => text } }, confirm) } async function checkSkillSwitch() { const a = Promise.withResolvers() const b = Promise.withResolvers() const drafts: { id: number, content: string }[] = [] const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }] const page = skills({ listSkillTree: async () => nodes, listSkillVersions: (id: number) => id === 1 ? a.promise : b.promise, saveSkillDraft: async (id: number, content: string) => { drafts.push({ id, content }) return { id: 20, version_no: 2, content } } }) await page.loadTree() const first = page.select(nodes[0]) const second = page.select(nodes[1]) assert.equal(await page.saveDraft(), null) b.resolve([{ id: 2, content: "B 原文", version_no: 1 }]) await second a.resolve([{ id: 1, content: "A 迟到", version_no: 1 }]) await first assert.equal(page.content.value, "B 原文") page.content.value = "B 修改" await page.saveDraft() assert.equal(drafts.length, 1) assert.equal(drafts[0].id, 2) assert.equal(drafts[0].content, "B 修改") } async function checkSkillReload() { const old = Promise.withResolvers() let calls = 0 const page = skills({ listSkillTree: async () => [{ id: 1, kind: "skill" }], listSkillVersions: () => ++calls === 1 ? old.promise : Promise.resolve([{ id: 2, content: "最新" }]) }) await page.loadTree() page.selectedId.value = 1 const first = page.loadContent() await page.loadContent() old.resolve([{ id: 1, content: "旧内容", version_no: 1 }]) await first assert.equal(page.content.value, "最新") } async function checkPublishSwitch() { const saved = Promise.withResolvers() const published: [number, number][] = [] const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }] const page = skills({ listSkillTree: async () => nodes, listSkillVersions: async (id: number) => [{ id, content: `内容 ${id}` }], saveSkillDraft: () => saved.promise, publishSkillVersion: async (id: number, version: number) => published.push([id, version]) }) await page.loadTree() await page.select(nodes[0]) page.content.value = "待发布修改" const publishing = page.publishLatest() await page.select(nodes[1]) saved.resolve({ id: 10, version_no: 2, content: "待发布修改" }) await publishing assert.deepEqual(published, []) assert.equal(page.content.value, "内容 2") } async function checkTickerSwitch() { const btc = Promise.withResolvers>() const eth = Promise.withResolvers>() const page = setupComponent("market/index.vue", { "@/apis/market": { getTicker: (_: string, symbol: string) => symbol === "BTCUSDT" ? btc.promise : eth.promise }, "@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) }, "./chart": { DEFAULT_FAVORITE_INTERVALS: [], INTERVAL_LABELS: {}, MAIN_INDICATORS: [], SUB_INDICATORS: [] }, "./useMarketSocket": { useMarketSocket: () => ({ connected: vue.ref(true), subscribe() {} }) }, "./components/AddSymbolDialog.vue": {}, "./components/SymbolManager.vue": {}, "klinecharts": {} }) const first = page.loadTicker() page.selection.value = { market: "um", symbol: "ETHUSDT" } await vue.nextTick() eth.resolve({ lastPrice: "2000", highPrice: "2100", lowPrice: "1900", volume: "5", quoteVolume: "10000", priceChangePercent: "1" }) await vue.nextTick() await new Promise(resolve => setImmediate(resolve)) btc.resolve({ lastPrice: "80000", highPrice: "81000", lowPrice: "79000", volume: "1", quoteVolume: "80000", priceChangePercent: "2" }) await first assert.equal(page.ticker.last, 2000) assert.equal(page.ticker.changePercent, 1) } async function checkPublishConfirmationSwitch() { const confirmation = Promise.withResolvers() const published: [number, number][] = [] const nodes = [{ id: 1, kind: "skill" }, { id: 2, kind: "skill" }] const page = skills({ listSkillTree: async () => nodes, listSkillVersions: async (id: number) => [{ id, content: `内容 ${id}`, version_no: 1 }], publishSkillVersion: async (id: number, version: number) => published.push([id, version]) }, () => confirmation.promise) await page.loadTree() await page.select(nodes[0]) const publishing = page.publishLatest() await Promise.resolve() await page.select(nodes[1]) confirmation.resolve() await publishing assert.deepEqual(published, []) } function contextEditor(api: Record) { return setupComponent("chat/components/ContextTab.vue", { "@/apis/chat": api, "@/utils/markdown": { renderMarkdown: (text: string) => text }, "@/utils/text": { formatTime: (text: string) => text } }) } async function checkContextRefresh() { const updated = Promise.withResolvers<{ id: number, content: string }>() const api = { getChatContext: async () => ({ id: 1, content: "原内容" }), listChatContextVersions: async () => [] } const page = contextEditor(api) await new Promise(resolve => setImmediate(resolve)) api.getChatContext = () => updated.promise const refresh = page.refreshFromChat() page.content.value = "刷新期间的新输入" updated.resolve({ id: 2, content: "Chat 更新" }) await refresh assert.equal(page.current.value.content, "Chat 更新") assert.equal(page.content.value, "刷新期间的新输入") assert.equal(page.dirty.value, true) } async function checkContextSave() { const saved = Promise.withResolvers<{ id: number, content: string }>() let submitted = "" const page = contextEditor({ getChatContext: async () => ({ id: 1, content: "原内容" }), listChatContextVersions: async () => [], saveChatContext: (content: string) => { submitted = content return saved.promise } }) await new Promise(resolve => setImmediate(resolve)) page.content.value = "提交的修改" const saving = page.save() page.content.value = "保存期间继续输入" saved.resolve({ id: 2, content: submitted }) await saving assert.equal(submitted, "提交的修改") assert.equal(page.current.value.content, "提交的修改") assert.equal(page.content.value, "保存期间继续输入") assert.equal(page.dirty.value, true) } async function checkContextReloadAfterSave() { const stale = Promise.withResolvers<{ id: number, content: string }>() const api = { getChatContext: async () => ({ id: 1, content: "原内容" }), listChatContextVersions: async () => [], saveChatContext: async (content: string) => ({ id: 2, content }) } const page = contextEditor(api) await new Promise(resolve => setImmediate(resolve)) api.getChatContext = () => stale.promise const loading = page.load() page.content.value = "已保存修改" await page.save() stale.resolve({ id: 1, content: "旧响应" }) await loading assert.equal(page.current.value.id, 2) assert.equal(page.content.value, "已保存修改") assert.equal(page.dirty.value, false) } async function checkContextRefreshDuringSave() { const saved = Promise.withResolvers<{ id: number, content: string }>() const page = contextEditor({ getChatContext: async () => ({ id: 1, content: "原内容" }), listChatContextVersions: async () => [], saveChatContext: () => saved.promise }) await new Promise(resolve => setImmediate(resolve)) page.content.value = "提交的修改" const saving = page.save() await page.load() assert.equal(page.content.value, "提交的修改") saved.resolve({ id: 2, content: "提交的修改" }) await saving assert.equal(page.dirty.value, false) } async function checkContextFailedSave() { const saved = Promise.withResolvers<{ id: number, content: string }>() const stale = Promise.withResolvers<{ id: number, content: string }>() const api = { getChatContext: async () => ({ id: 1, content: "原内容" }), listChatContextVersions: async () => [], saveChatContext: () => saved.promise } const page = contextEditor(api) await new Promise(resolve => setImmediate(resolve)) page.content.value = "保存失败后应保留的草稿" const saving = page.save() api.getChatContext = () => stale.promise const loading = page.load() saved.reject(new Error("保存失败")) await assert.rejects(saving, /保存失败/) stale.resolve({ id: 1, content: "原内容" }) await loading assert.equal(page.content.value, "保存失败后应保留的草稿") assert.equal(page.dirty.value, true) assert.equal(page.saving.value, false) } await checkSkillSwitch() await checkSkillReload() await checkPublishSwitch() await checkTickerSwitch() await checkPublishConfirmationSwitch() await checkContextRefresh() await checkContextSave() await checkContextReloadAfterSave() await checkContextRefreshDuringSave() await checkContextFailedSave() console.log("页面异步竞态:10 项回归检查通过")