import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { effectScope, nextTick, reactive } from 'vue' import { compileScript, parse } from '@vue/compiler-sfc' import ts from 'typescript' const componentPath = 'src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue' const componentSource = await readFile(componentPath, 'utf8') const messages = [] const requests = [] let rememberedClears = 0 let localStateClears = 0 let response = { status: 200, data: { code: 0, msg: 'ok', data: null } } let pendingRequest function moduleUrl(source, imports = {}) { const { outputText } = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext } }) const resolved = outputText.replace(/from ['"]([^'"]+)['"]/g, (_match, name) => { return `from '${imports[name] || import.meta.resolve(name)}'` }) return `data:text/javascript;base64,${Buffer.from(resolved).toString('base64')}` } // 真实请求层使用模拟 Axios adapter:不会访问网络或修改真实账号密码。 const axios = (await import('axios')).default const previousAdapter = axios.defaults.adapter globalThis.__passwordTest = { message: (type, value) => messages.push({ type, value }), clearRemembered: () => rememberedClears++, store: { accessToken: 'mock-access-token', refreshToken: '', clearLocalState: () => localStateClears++ } } const uiUrl = moduleUrl(` export const ElButton = {}, ElDialog = {}, ElForm = {}, ElFormItem = {}, ElInput = {} export const ElMessage = { success: (value) => globalThis.__passwordTest.message('success', value), error: (value) => globalThis.__passwordTest.message('error', value) } `) const storeUrl = moduleUrl('export const useUserStore = () => globalThis.__passwordTest.store') const statusUrl = moduleUrl(await readFile('src/utils/http/status.ts', 'utf8')) const errorUrl = moduleUrl(await readFile('src/utils/business/apiError.ts', 'utf8')) const idUrl = moduleUrl(await readFile('src/utils/business/id.ts', 'utf8')) const httpSource = (await readFile('src/utils/http/index.ts', 'utf8')) .replace('import.meta.env.DEV', 'true') .replace('import.meta.env.VITE_API_URL', "''") const httpUrl = moduleUrl(httpSource, { 'element-plus': uiUrl, '@/store/modules/user': storeUrl, './status': statusUrl, '@/utils/business/apiError': errorUrl, '@/utils/business/id': idUrl }) // 导入时才创建 Axios 实例,因此临时保留模拟 adapter 到模块初始化结束。 const mockAdapter = async (config) => { requests.push(config) if (pendingRequest) await pendingRequest const result = { ...response, headers: { 'content-type': 'application/json' }, config } if (response.status >= 400) { throw new axios.AxiosError('Mock HTTP error', 'ERR_BAD_RESPONSE', config, undefined, result) } return result } axios.defaults.adapter = mockAdapter await import(httpUrl) axios.defaults.adapter = previousAdapter const baseUrl = moduleUrl(await readFile('src/api/BaseService.ts', 'utf8'), { '@/utils/http': httpUrl }) const authUrl = moduleUrl(await readFile('src/api/modules/auth.ts', 'utf8'), { '../BaseService': baseUrl }) const { AuthService } = await import(authUrl) const { descriptor } = parse(componentSource, { filename: componentPath }) const script = compileScript(descriptor, { id: 'password-test' }) const { default: Dialog } = await import( moduleUrl(script.content, { 'element-plus': uiUrl, 'vue-i18n': moduleUrl('export const useI18n = () => ({ t: (key) => key })'), '@/api/modules/auth': authUrl, '@/utils/http/status': statusUrl, '@/utils/auth/rememberPassword': moduleUrl( 'export const clearRememberedCredentials = () => globalThis.__passwordTest.clearRemembered()' ) }) ) function createDialog() { response = { status: 200, data: { code: 0, msg: 'ok', data: null } } pendingRequest = undefined requests.length = 0 messages.length = 0 rememberedClears = 0 const props = reactive({ modelValue: true }) const scope = effectScope() const state = scope.run(() => Dialog.setup(props, { expose: () => {}, emit: (_event, value) => { props.modelValue = value } }) ) let clearedValidation = 0 async function validateField(field) { const value = state.formData[field] for (const rule of state.rules.value[field]) { if (rule.required && !value) throw new Error(rule.message) if (rule.min !== undefined && value.length < rule.min) throw new Error(rule.message) if (rule.max !== undefined && value.length > rule.max) throw new Error(rule.message) if (rule.validator) { await new Promise((resolve, reject) => { rule.validator(rule, value, (error) => (error ? reject(error) : resolve())) }) } } } state.formRef.value = { validate: async () => { for (const field of Object.keys(state.rules.value)) await validateField(field) return true }, validateField, clearValidate: () => clearedValidation++ } Object.assign(state.formData, { oldPassword: 'old', newPassword: 'new-password', confirmPassword: 'new-password' }) return { state, props, scope, validationClears: () => clearedValidation } } test('密码长度 7/33 位阻止提交,8/32 位允许提交,旧密码不受新长度限制', async () => { for (const length of [7, 8, 32, 33]) { const { state, props, scope } = createDialog() state.formData.newPassword = 'a'.repeat(length) state.formData.confirmPassword = 'a'.repeat(length) await state.handleSubmit() assert.equal(requests.length, length === 8 || length === 32 ? 1 : 0) assert.equal(props.modelValue, length !== 8 && length !== 32) assert.equal(state.submitting.value, false) scope.stop() } }) test('密码必填、一致性及确认密码长度校验失败不发送请求', async () => { for (const invalid of [ { oldPassword: '' }, { newPassword: '' }, { confirmPassword: '' }, { confirmPassword: 'different-password' }, { confirmPassword: 'a'.repeat(7) }, { confirmPassword: 'a'.repeat(33) } ]) { const { state, scope } = createDialog() Object.assign(state.formData, invalid) await state.handleSubmit() assert.equal(requests.length, 0) assert.equal(state.submitting.value, false) scope.stop() } }) test('修改成功发送下划线参数及 Bearer 认证、保留密码空格、清理表单但不退出登录', async () => { const { state, props, scope } = createDialog() state.formData.oldPassword = ' old ' state.formData.newPassword = state.formData.confirmPassword = ' new-password ' await state.handleSubmit() assert.equal(requests[0].method, 'put') assert.equal(requests[0].url, '/api/auth/password') assert.equal(requests[0].headers.get('Authorization'), 'Bearer mock-access-token') assert.deepEqual(JSON.parse(requests[0].data), { old_password: ' old ', new_password: ' new-password ' }) assert.equal(rememberedClears, 1) assert.equal(localStateClears, 0) assert.equal(props.modelValue, false) assert.deepEqual({ ...state.formData }, { oldPassword: '', newPassword: '', confirmPassword: '' }) assert.deepEqual(messages, [{ type: 'success', value: 'changePassword.success' }]) scope.stop() }) test('服务层丢弃确认密码和其他额外字段', async () => { createDialog().scope.stop() await AuthService.changePassword({ old_password: 'old', new_password: 'new-password', confirmPassword: 'unused' }) assert.deepEqual(JSON.parse(requests[0].data), { old_password: 'old', new_password: 'new-password' }) }) test('校验或请求未完成时重复提交只产生一次请求,并阻止关闭', async () => { const { state, props, scope } = createDialog() let finishRequest pendingRequest = new Promise((resolve) => { finishRequest = resolve }) const first = state.handleSubmit() await state.handleSubmit() await nextTick() assert.equal(state.submitting.value, true) let closed = false state.handleBeforeClose(() => { closed = true }) assert.equal(closed, false) assert.equal(props.modelValue, true) finishRequest() await first assert.equal(requests.length, 1) scope.stop() }) test('HTTP 400/500 错误保留弹窗和输入,统一请求层提示一次且允许重试', async () => { for (const status of [400, 500]) { const { state, props, scope } = createDialog() response = { status, data: { code: status, msg: '旧密码错误', data: {} } } await state.handleSubmit() assert.equal(props.modelValue, true) assert.equal(state.formData.oldPassword, 'old') assert.equal(state.submitting.value, false) assert.equal(rememberedClears, 0) assert.equal(messages.length, 1) assert.equal(messages[0].type, 'error') response = { status: 200, data: { code: 0, msg: 'ok', data: null } } await state.handleSubmit() assert.equal(props.modelValue, false) assert.equal(requests.length, 2) scope.stop() } }) test('HTTP 200 业务错误不会误报成功或清除保存的凭证', async () => { const { state, props, scope } = createDialog() response = { status: 200, data: { code: 1001, msg: '旧密码错误', data: {} } } await state.handleSubmit() assert.equal(props.modelValue, true) assert.equal(rememberedClears, 0) assert.deepEqual(messages, [{ type: 'error', value: '旧密码错误' }]) scope.stop() }) test('关闭后重新打开清空密码及校验状态', async () => { const { state, props, scope, validationClears } = createDialog() state.visible.value = false state.resetForm() props.modelValue = true await nextTick() assert.deepEqual({ ...state.formData }, { oldPassword: '', newPassword: '', confirmPassword: '' }) assert.ok(validationClears() > 0) assert.equal(state.submitting.value, false) scope.stop() }) test('菜单顺序、显隐、标签、键盘提交、窄屏宽度及中英文文案完整', async () => { const header = await readFile('src/components/core/layouts/art-header-bar/index.vue', 'utf8') assert.ok(header.indexOf('topBar.user.userCenter') < header.indexOf('topBar.user.changePassword')) assert.ok(header.indexOf('topBar.user.changePassword') < header.indexOf('topBar.user.lockScreen')) assert.match(header, /userMenuPopover\.value\?\.hide\(\)[\s\S]*showChangePassword\.value = true/) assert.equal(componentSource.match(/show-password/g)?.length, 3) assert.match(componentSource, /width="min\(460px, calc\(100vw - 32px\)\)"/) assert.match(componentSource, /label-position="top"/) assert.match(componentSource, /@submit\.prevent="handleSubmit"/) assert.match(header, /:focus-visible/) for (const language of ['zh', 'en']) { const dictionary = JSON.parse(await readFile(`src/locales/langs/${language}.json`, 'utf8')) for (const [, key] of componentSource.matchAll(/t\('changePassword\.([^']+)'\)/g)) { assert.ok(dictionary.changePassword[key], `${language}: ${key}`) } assert.ok(dictionary.topBar.user.changePassword) } }) test('HTTP 401 沿用统一认证过期处理,仅提示一次并清理本地登录状态', async () => { const { state, scope } = createDialog() response = { status: 401, data: { code: 401, msg: 'unauthorized', data: {} } } await state.handleSubmit() assert.equal(state.submitting.value, false) assert.equal(rememberedClears, 0) assert.deepEqual(messages, [{ type: 'error', value: '登录已过期,请重新登录' }]) await new Promise((resolve) => setTimeout(resolve, 1050)) assert.equal(localStateClears, 1) scope.stop() }) test.after(() => { delete globalThis.__passwordTest })