forked from wangziqi/gongxue-base
fix: align Taro H5 with legacy question bank UI
This commit is contained in:
@@ -365,7 +365,22 @@ const platformPermissionCatalog = [
|
||||
function mockApiPayload(pathname, method, query, body) {
|
||||
if (pathname === '/health') return { ok: true };
|
||||
if (pathname === '/api/tenant/resolve') return baseTenantPayload(query);
|
||||
if (pathname === '/api/auth/me') return { user: { id: ids.user, name: '测试学生' }, item: { id: ids.user, name: '测试学生' } };
|
||||
if (pathname === '/api/auth/me') {
|
||||
return {
|
||||
user: {
|
||||
id: ids.user,
|
||||
name: '测试学生',
|
||||
primaryRole: 'platform_admin',
|
||||
roles: ['platform_admin', 'tenant_admin'],
|
||||
},
|
||||
item: {
|
||||
id: ids.user,
|
||||
name: '测试学生',
|
||||
primaryRole: 'platform_admin',
|
||||
roles: ['platform_admin', 'tenant_admin'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname === '/api/catalog/content-entries') {
|
||||
return {
|
||||
@@ -1082,7 +1097,8 @@ class CdpPage {
|
||||
returnByValue: true,
|
||||
});
|
||||
if (result.exceptionDetails) {
|
||||
throw new Error(result.exceptionDetails.text || 'Runtime evaluation failed');
|
||||
const description = result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'Runtime evaluation failed';
|
||||
throw new Error(description);
|
||||
}
|
||||
return result.result?.value;
|
||||
}
|
||||
@@ -1276,7 +1292,7 @@ async function clickText(page, text) {
|
||||
`);
|
||||
await page.acceptDialogs();
|
||||
if (!result?.ok) throw new Error(`Clickable text not found: ${text}\n${result?.body || ''}`);
|
||||
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y);
|
||||
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {});
|
||||
await delay(350);
|
||||
await page.acceptDialogs();
|
||||
return result;
|
||||
@@ -1344,7 +1360,7 @@ async function clickTextInSection(page, sectionTitle, text) {
|
||||
`);
|
||||
await page.acceptDialogs();
|
||||
if (!result?.ok) throw new Error(`Clickable text not found in section "${sectionTitle}": ${text}\n${result?.body || ''}`);
|
||||
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y);
|
||||
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {});
|
||||
await delay(350);
|
||||
await page.acceptDialogs();
|
||||
return result;
|
||||
@@ -1431,7 +1447,7 @@ async function clickVisibleTextCandidate(page, texts) {
|
||||
})()
|
||||
`);
|
||||
if (result?.ok) {
|
||||
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y);
|
||||
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {});
|
||||
await delay(250);
|
||||
await page.acceptDialogs();
|
||||
}
|
||||
@@ -1524,16 +1540,45 @@ async function fillByPlaceholder(page, placeholder, value, occurrence = 0) {
|
||||
const expected = ${JSON.stringify(placeholder)};
|
||||
const nextValue = ${JSON.stringify(value)};
|
||||
const occurrence = ${Number(occurrence)};
|
||||
const elements = Array.from(document.querySelectorAll('input, textarea'))
|
||||
const visible = el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const nativeElements = Array.from(document.querySelectorAll('input, textarea'))
|
||||
.filter(visible)
|
||||
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
|
||||
const customElements = Array.from(document.querySelectorAll('taro-input-core, taro-textarea-core'))
|
||||
.filter(visible)
|
||||
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
|
||||
const elements = nativeElements.length ? nativeElements : customElements;
|
||||
const target = elements[occurrence];
|
||||
if (!target) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
||||
target.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;
|
||||
const previousValue = target.value;
|
||||
const nativePrototype = target instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : target instanceof HTMLInputElement ? HTMLInputElement.prototype : null;
|
||||
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set || (nativePrototype ? Object.getOwnPropertyDescriptor(nativePrototype, 'value')?.set : null);
|
||||
if (setter) setter.call(target, nextValue);
|
||||
else target.value = nextValue;
|
||||
for (const type of ['input', 'change']) {
|
||||
target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true }));
|
||||
target.setAttribute('value', nextValue);
|
||||
if (target._valueTracker) target._valueTracker.setValue(previousValue);
|
||||
target.focus();
|
||||
const makeInputEvent = type => {
|
||||
try {
|
||||
return new InputEvent(type, { bubbles: true, cancelable: true, data: nextValue, inputType: 'insertText' });
|
||||
} catch {
|
||||
return new Event(type, { bubbles: true, cancelable: true });
|
||||
}
|
||||
};
|
||||
for (const event of [
|
||||
makeInputEvent('beforeinput'),
|
||||
makeInputEvent('input'),
|
||||
new CustomEvent('input', { bubbles: true, cancelable: true, detail: { value: nextValue } }),
|
||||
new Event('compositionend', { bubbles: true, cancelable: true }),
|
||||
new CustomEvent('change', { bubbles: true, cancelable: true, detail: { value: nextValue } }),
|
||||
new Event('change', { bubbles: true, cancelable: true }),
|
||||
]) {
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
return { ok: true, placeholder: target.getAttribute('placeholder'), value: target.value };
|
||||
})()
|
||||
@@ -1560,16 +1605,40 @@ async function fillByPlaceholderInSection(page, sectionTitle, placeholder, value
|
||||
.filter(el => visible(el) && normalized(el.innerText || el.textContent || '').includes(sectionTitle));
|
||||
const root = sections.sort((a, b) => normalized(a.innerText || a.textContent || '').length - normalized(b.innerText || b.textContent || '').length)[0];
|
||||
if (!root) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
||||
const elements = Array.from(root.querySelectorAll('input, textarea'))
|
||||
const nativeElements = Array.from(root.querySelectorAll('input, textarea'))
|
||||
.filter(visible)
|
||||
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
|
||||
const customElements = Array.from(root.querySelectorAll('taro-input-core, taro-textarea-core'))
|
||||
.filter(visible)
|
||||
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
|
||||
const elements = nativeElements.length ? nativeElements : customElements;
|
||||
const target = elements[occurrence];
|
||||
if (!target) return { ok: false, body: normalized(root.innerText || root.textContent || '').slice(0, 1200) };
|
||||
target.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;
|
||||
const previousValue = target.value;
|
||||
const nativePrototype = target instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : target instanceof HTMLInputElement ? HTMLInputElement.prototype : null;
|
||||
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set || (nativePrototype ? Object.getOwnPropertyDescriptor(nativePrototype, 'value')?.set : null);
|
||||
if (setter) setter.call(target, nextValue);
|
||||
else target.value = nextValue;
|
||||
for (const type of ['input', 'change']) {
|
||||
target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true }));
|
||||
target.setAttribute('value', nextValue);
|
||||
if (target._valueTracker) target._valueTracker.setValue(previousValue);
|
||||
target.focus();
|
||||
const makeInputEvent = type => {
|
||||
try {
|
||||
return new InputEvent(type, { bubbles: true, cancelable: true, data: nextValue, inputType: 'insertText' });
|
||||
} catch {
|
||||
return new Event(type, { bubbles: true, cancelable: true });
|
||||
}
|
||||
};
|
||||
for (const event of [
|
||||
makeInputEvent('beforeinput'),
|
||||
makeInputEvent('input'),
|
||||
new CustomEvent('input', { bubbles: true, cancelable: true, detail: { value: nextValue } }),
|
||||
new Event('compositionend', { bubbles: true, cancelable: true }),
|
||||
new CustomEvent('change', { bubbles: true, cancelable: true, detail: { value: nextValue } }),
|
||||
new Event('change', { bubbles: true, cancelable: true }),
|
||||
]) {
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
return { ok: true, placeholder: target.getAttribute('placeholder'), value: target.value };
|
||||
})()
|
||||
@@ -1579,6 +1648,38 @@ async function fillByPlaceholderInSection(page, sectionTitle, placeholder, value
|
||||
return result;
|
||||
}
|
||||
|
||||
async function formDiagnostics(page, placeholder = '') {
|
||||
return page.evaluate(`
|
||||
(() => {
|
||||
const expected = ${JSON.stringify(placeholder)};
|
||||
const visible = el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim();
|
||||
return {
|
||||
path: location.pathname + location.search + location.hash,
|
||||
body: normalized(document.body?.innerText || '').slice(0, 1600),
|
||||
fields: Array.from(document.querySelectorAll('input, textarea, taro-input-core, taro-textarea-core'))
|
||||
.filter(el => !expected || String(el.getAttribute('placeholder') || '').includes(expected))
|
||||
.map(el => ({
|
||||
tag: el.tagName,
|
||||
className: String(el.className || ''),
|
||||
placeholder: el.getAttribute('placeholder') || '',
|
||||
value: el.value || el.getAttribute('value') || '',
|
||||
visible: visible(el),
|
||||
})),
|
||||
buttons: Array.from(document.querySelectorAll('button,taro-button-core,a,[role="button"],[onclick],[class*="button"],[class*="btn"]'))
|
||||
.filter(visible)
|
||||
.map(el => ({ tag: el.tagName, className: String(el.className || ''), text: normalized(el.innerText || el.textContent || '').slice(0, 140) }))
|
||||
.filter(item => item.text.includes('预览') || item.text.includes('导入') || item.text.includes('题库') || item.text.includes('json'))
|
||||
.slice(0, 30),
|
||||
};
|
||||
})()
|
||||
`).catch(error => ({ error: error.message }));
|
||||
}
|
||||
|
||||
async function ensureInputValue(page, placeholder, value, occurrence = 0) {
|
||||
const current = await page.evaluate(`
|
||||
(() => {
|
||||
@@ -1718,7 +1819,13 @@ async function runTenantJourney(browser, portal, api) {
|
||||
|
||||
await fillByPlaceholder(page, '粘贴题目', '[{"title":"smoke","type":"single_choice"}]');
|
||||
await clickText(page, '后端预览');
|
||||
await waitForApiRequest(api, '/api/tenant-content/imports/preview/questions', 'POST');
|
||||
try {
|
||||
await waitForApiRequest(api, '/api/tenant-content/imports/preview/questions', 'POST');
|
||||
} catch (error) {
|
||||
const diagnostics = await formDiagnostics(page, '粘贴题目');
|
||||
const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`);
|
||||
throw new Error(`${error.message}\nContent import diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`);
|
||||
}
|
||||
await waitForText(page, '预览第');
|
||||
await clickTextAndConfirm(page, '执行导入');
|
||||
await waitForApiRequest(api, '/api/tenant-content/imports/questions', 'POST');
|
||||
@@ -1810,10 +1917,16 @@ async function runTenantJourney(browser, portal, api) {
|
||||
await clickText(page, '保存模板');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/role-templates', 'PUT');
|
||||
await clickText(page, '新建成员');
|
||||
await fillByPlaceholder(page, '姓名', 'H5 烟测成员', 1);
|
||||
await fillByPlaceholder(page, '手机号', '13911112222', 1);
|
||||
await fillByPlaceholderInSection(page, '新建成员权限', '姓名', 'H5 烟测成员');
|
||||
await fillByPlaceholderInSection(page, '新建成员权限', '手机号', '13911112222');
|
||||
await clickText(page, '保存成员');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/members', 'PUT');
|
||||
try {
|
||||
await waitForApiRequest(api, '/api/tenant-admin/members', 'PUT');
|
||||
} catch (error) {
|
||||
const diagnostics = await formDiagnostics(page, '姓名');
|
||||
const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`);
|
||||
throw new Error(`${error.message}\nMember diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`);
|
||||
}
|
||||
checks.push({ id: 'tenant.settings.brand_role_member', status: 'pass', detail: '主题草稿/发布、角色模板和成员绑定 API 已触发' });
|
||||
return checks;
|
||||
} finally {
|
||||
@@ -1853,13 +1966,19 @@ async function runPlatformJourney(browser, portal, api) {
|
||||
await waitForText(page, '账务中心');
|
||||
checks.push({ id: 'platform.workbench.to_billing', status: 'pass', detail: await currentPath(page) });
|
||||
|
||||
await ensureInputValue(page, 'tenantId', ids.tenant, 0);
|
||||
await ensureInputValue(page, 'starter_yearly', 'starter_yearly');
|
||||
await fillByPlaceholderInSection(page, '订阅与账单操作', 'tenantId', ids.tenant, 0);
|
||||
await fillByPlaceholderInSection(page, '订阅与账单操作', 'starter_yearly', 'starter_yearly');
|
||||
await clickTextAndConfirm(page, '开通订阅');
|
||||
await waitForApiRequest(api, '/api/platform-admin/subscriptions', 'POST');
|
||||
await ensureInputValue(page, 'tenantId', ids.tenant, 1);
|
||||
await fillByPlaceholderInSection(page, '订阅与账单操作', 'tenantId', ids.tenant, 1);
|
||||
await clickTextAndConfirm(page, '生成订阅账单');
|
||||
await waitForApiRequest(api, '/api/platform-admin/invoices/from-subscription', 'POST');
|
||||
try {
|
||||
await waitForApiRequest(api, '/api/platform-admin/invoices/from-subscription', 'POST');
|
||||
} catch (error) {
|
||||
const diagnostics = await formDiagnostics(page, 'tenantId');
|
||||
const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`);
|
||||
throw new Error(`${error.message}\nBilling diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`);
|
||||
}
|
||||
await fillByPlaceholderInSection(page, '收款与用量', 'tenantId', ids.tenant, 0);
|
||||
await fillByPlaceholderInSection(page, '收款与用量', 'invoiceId', ids.invoice);
|
||||
await fillByPlaceholderInSection(page, '收款与用量', '元', '1999');
|
||||
|
||||
@@ -7,6 +7,7 @@ const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src');
|
||||
const pagesRoot = path.join(taroSrc, 'pages');
|
||||
const appConfigPath = path.join(taroSrc, 'app.config.ts');
|
||||
const bootstrapPath = path.join(pagesRoot, 'bootstrap', 'index.tsx');
|
||||
const routeGuardPath = path.join(taroSrc, 'services', 'routeGuard.ts');
|
||||
const h5StaticSmokePath = path.join(repoRoot, 'scripts', 'taro-h5-static-smoke.js');
|
||||
const frontendHandoffPath = path.join(repoRoot, 'docs', 'refactor', 'frontend-handoff-index.md');
|
||||
|
||||
@@ -34,13 +35,23 @@ function walkIndexPages(dir) {
|
||||
|
||||
function parseAppRoutes() {
|
||||
const text = readText(appConfigPath);
|
||||
const match = text.match(/pages\s*:\s*\[([\s\S]*?)\]/m);
|
||||
assert.ok(match, 'apps/taro/src/app.config.ts must define pages: [...]');
|
||||
const match = text.match(/const\s+allPageRoutes\s*=\s*\[([\s\S]*?)\]/m);
|
||||
assert.ok(match, 'apps/taro/src/app.config.ts must define const allPageRoutes = [...]');
|
||||
return [...match[1].matchAll(/['"`]([^'"`]+)['"`]/g)]
|
||||
.map(item => item[1].trim())
|
||||
.filter(route => route.startsWith('pages/'));
|
||||
}
|
||||
|
||||
function parsePortalLandingRoutes() {
|
||||
const text = readText(appConfigPath);
|
||||
const match = text.match(/const\s+portalLandingRoutes\s*:\s*Record<string,\s*string>\s*=\s*\{([\s\S]*?)\}/m);
|
||||
assert.ok(match, 'apps/taro/src/app.config.ts must define portalLandingRoutes');
|
||||
return Object.fromEntries(
|
||||
[...match[1].matchAll(/['"`]?([\w-]+)['"`]?\s*:\s*['"`]([^'"`]+)['"`]/g)]
|
||||
.map(item => [item[1], item[2]]),
|
||||
);
|
||||
}
|
||||
|
||||
function routeFromIndexFile(filePath) {
|
||||
return normalizeSlashes(path.relative(taroSrc, filePath)).replace(/\/index\.tsx$/, '/index');
|
||||
}
|
||||
@@ -59,9 +70,9 @@ const appRoutes = parseAppRoutes();
|
||||
const appRouteSet = new Set(appRoutes);
|
||||
const actualRoutes = walkIndexPages(pagesRoot).map(routeFromIndexFile);
|
||||
const actualRouteSet = new Set(actualRoutes);
|
||||
const portalLandingRouteMap = parsePortalLandingRoutes();
|
||||
|
||||
assert.equal(appRoutes.length, appRouteSet.size, 'app.config.ts must not contain duplicate page routes');
|
||||
assert.equal(appRoutes[0], 'pages/bootstrap/index', 'The first Taro page must be the bootstrap page for tenant/runtime config resolution');
|
||||
|
||||
const missingFiles = appRoutes.filter(route => !actualRouteSet.has(route));
|
||||
const unregisteredPages = actualRoutes.filter(route => !appRouteSet.has(route));
|
||||
@@ -73,22 +84,37 @@ const expectedPortalLandingRoutes = [
|
||||
'pages/student/home/index',
|
||||
'pages/tenant-admin/workbench/index',
|
||||
];
|
||||
const bootstrapPageRoutes = parseLiteralPagePaths(readText(bootstrapPath));
|
||||
const bootstrapLandingRoutes = bootstrapPageRoutes.filter(route => route !== 'pages/bootstrap/index');
|
||||
assert.deepEqual(
|
||||
uniqueSorted(bootstrapLandingRoutes),
|
||||
uniqueSorted(Object.values(portalLandingRouteMap)),
|
||||
expectedPortalLandingRoutes,
|
||||
'Bootstrap landing routes must stay explicit for the three H5 portals',
|
||||
'Portal-specific H5 builds must put each portal landing route first',
|
||||
);
|
||||
for (const route of bootstrapPageRoutes) {
|
||||
assert.ok(appRouteSet.has(route), `Bootstrap route is not registered in app.config.ts: ${route}`);
|
||||
for (const route of Object.values(portalLandingRouteMap)) {
|
||||
assert.ok(appRouteSet.has(route), `Portal landing route is not registered in app.config.ts: ${route}`);
|
||||
}
|
||||
|
||||
const bootstrapPageRoutes = parseLiteralPagePaths(readText(bootstrapPath));
|
||||
const routeGuardPageRoutes = parseLiteralPagePaths(readText(routeGuardPath));
|
||||
const publicRoutes = new Set([
|
||||
'pages/bootstrap/index',
|
||||
'pages/student/login/index',
|
||||
'pages/student/region/index',
|
||||
]);
|
||||
const routeGuardLandingRoutes = routeGuardPageRoutes.filter(route => !publicRoutes.has(route));
|
||||
assert.deepEqual(
|
||||
uniqueSorted(routeGuardLandingRoutes),
|
||||
expectedPortalLandingRoutes,
|
||||
'Route guard landing routes must stay explicit for the three H5 portals',
|
||||
);
|
||||
for (const route of [...bootstrapPageRoutes, ...routeGuardPageRoutes]) {
|
||||
assert.ok(appRouteSet.has(route), `Referenced route is not registered in app.config.ts: ${route}`);
|
||||
}
|
||||
|
||||
const staticSmokeLandingRoutes = parseLiteralPagePaths(readText(h5StaticSmokePath));
|
||||
assert.deepEqual(
|
||||
uniqueSorted(staticSmokeLandingRoutes),
|
||||
uniqueSorted(bootstrapLandingRoutes),
|
||||
'H5 static smoke landing routes must match bootstrap landing routes',
|
||||
uniqueSorted(routeGuardLandingRoutes),
|
||||
'H5 static smoke landing routes must match route guard landing routes',
|
||||
);
|
||||
for (const route of staticSmokeLandingRoutes) {
|
||||
assert.ok(appRouteSet.has(route), `H5 static smoke landing route is not registered in app.config.ts: ${route}`);
|
||||
|
||||
Reference in New Issue
Block a user