feat: finalize no-iframe portal shell route-view migration

This commit is contained in:
egg
2026-02-11 17:07:50 +08:00
parent ccab10bee8
commit 1e7f8f4498
100 changed files with 8794 additions and 642 deletions

View File

@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
function readSource(relativePath) {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8');
}
test('portal shell app renders health summary component and admin entry controls', () => {
const source = readSource('src/portal-shell/App.vue');
assert.match(source, /import HealthStatus from '\.\/components\/HealthStatus\.vue';/);
assert.match(source, /<HealthStatus \/>/);
assert.match(source, /管理後台/);
assert.match(source, /管理員登入/);
assert.match(source, /登出/);
assert.match(source, /adminLinks\?\.pages/);
});
test('portal shell app keeps fallback notice and route sync wiring', () => {
const source = readSource('src/portal-shell/App.vue');
assert.doesNotMatch(source, /class="mode-badge"/);
assert.match(source, /consumeNavigationNotice/);
assert.match(source, /syncNavigationRoutes\(payload\.drawers/);
});

View File

@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
buildHealthFallbackDetail,
labelFromHealthStatus,
normalizeFrontendShellHealth,
} from '../src/portal-shell/healthSummary.js';
function readSource(relativePath) {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8');
}
test('labelFromHealthStatus maps healthy/degraded/unhealthy states', () => {
assert.equal(labelFromHealthStatus('healthy'), '連線正常');
assert.equal(labelFromHealthStatus('degraded'), '部分降級');
assert.equal(labelFromHealthStatus('unhealthy'), '連線異常');
});
test('normalizeFrontendShellHealth reads summary/detail contract', () => {
const normalized = normalizeFrontendShellHealth({
summary: { status: 'healthy' },
detail: { errors: ['none'] },
});
assert.equal(normalized.status, 'healthy');
assert.deepEqual(normalized.errors, ['none']);
});
test('normalizeFrontendShellHealth supports legacy flat payload shape', () => {
const normalized = normalizeFrontendShellHealth({
status: 'degraded',
errors: ['asset missing'],
});
assert.equal(normalized.status, 'degraded');
assert.deepEqual(normalized.errors, ['asset missing']);
});
test('buildHealthFallbackDetail returns deterministic fallback contract', () => {
const fallback = buildHealthFallbackDetail();
assert.equal(fallback.status, 'unhealthy');
assert.equal(fallback.label, '無法連線');
assert.equal(fallback.detail.database, '無法確認');
assert.equal(fallback.detail.routeCacheMode, '--');
});
test('HealthStatus component keeps summary-first trigger and detail panel interactions', () => {
const source = readSource('src/portal-shell/components/HealthStatus.vue');
assert.match(source, /class=\"health-trigger\"/);
assert.match(source, /meta-toggle\">詳情/);
assert.match(source, /v-if=\"popupOpen\"/);
// Close-on-outside-click and ESC behavior remain part of the UX contract.
assert.match(source, /document\.addEventListener\('click', onDocumentClick\)/);
assert.match(source, /document\.addEventListener\('keydown', onDocumentKeydown\)/);
assert.match(source, /event\.key === 'Escape'/);
});

View File

@@ -0,0 +1,107 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildDynamicNavigationState,
normalizeNavigationDrawers,
} from '../src/portal-shell/navigationState.js';
test('normalizeNavigationDrawers enforces deterministic order and visibility', () => {
const input = [
{
id: 'dev-tools',
name: 'Dev',
order: 3,
admin_only: true,
pages: [{ route: '/admin/pages', name: 'Admin Pages', status: 'dev', order: 2 }],
},
{
id: 'reports',
name: 'Reports',
order: 1,
admin_only: false,
pages: [
{ route: '/wip-overview', name: 'WIP', status: 'released', order: 2 },
{ route: '/hold-overview', name: 'Hold', status: 'dev', order: 1 },
],
},
];
const nonAdmin = normalizeNavigationDrawers(input, { isAdmin: false });
assert.deepEqual(nonAdmin.map((d) => d.id), ['reports']);
assert.deepEqual(nonAdmin[0].pages.map((p) => p.route), ['/wip-overview']);
const admin = normalizeNavigationDrawers(input, { isAdmin: true });
assert.deepEqual(admin.map((d) => d.id), ['reports', 'dev-tools']);
assert.deepEqual(admin[0].pages.map((p) => p.route), ['/hold-overview', '/wip-overview']);
});
test('buildDynamicNavigationState resolves render mode via route contract', () => {
const state = buildDynamicNavigationState(
[
{
id: 'reports',
name: 'Reports',
order: 1,
pages: [{ route: '/wip-overview', name: 'WIP', status: 'released', order: 1 }],
},
{
id: 'tools',
name: 'Tools',
order: 2,
pages: [{ route: '/job-query', name: 'Job Query', status: 'released', order: 1 }],
},
],
{ isAdmin: true },
);
const renderModes = Object.fromEntries(
state.dynamicRoutes.map((route) => [route.targetRoute, route.renderMode]),
);
assert.equal(renderModes['/wip-overview'], 'native');
assert.equal(renderModes['/job-query'], 'native');
assert.equal(state.diagnostics.missingContractRoutes.length, 0);
});
test('buildDynamicNavigationState tracks routes missing from contract and keeps native fallback mode', () => {
const state = buildDynamicNavigationState(
[
{
id: 'reports',
name: 'Reports',
order: 1,
pages: [{ route: '/legacy-unknown', name: 'Legacy', status: 'released', order: 1 }],
},
],
{ isAdmin: false },
);
assert.deepEqual(state.diagnostics.missingContractRoutes, ['/legacy-unknown']);
assert.equal(state.dynamicRoutes[0].renderMode, 'native');
assert.deepEqual(state.allowedPaths.sort(), ['/', '/legacy-unknown']);
});
test('buildDynamicNavigationState can include standalone drilldown routes without drawer entries', () => {
const state = buildDynamicNavigationState(
[
{
id: 'reports',
name: 'Reports',
order: 1,
pages: [{ route: '/wip-overview', name: 'WIP', status: 'released', order: 1 }],
},
],
{ isAdmin: false, includeStandaloneDrilldown: true },
);
const targetRoutes = state.dynamicRoutes.map((route) => route.targetRoute);
assert.equal(targetRoutes.includes('/wip-overview'), true);
assert.equal(targetRoutes.includes('/wip-detail'), true);
assert.equal(targetRoutes.includes('/hold-detail'), true);
assert.equal(state.allowedPaths.includes('/wip-detail'), true);
assert.equal(state.allowedPaths.includes('/hold-detail'), true);
});

View File

@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
function readSource(relativePath) {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8');
}
test('shell route host sources do not contain iframe rendering paths', () => {
const files = [
'src/portal-shell/App.vue',
'src/portal-shell/views/NativeRouteView.vue',
'src/portal-shell/router.js',
'src/portal-shell/navigationState.js',
];
files.forEach((filePath) => {
const source = readSource(filePath).toLowerCase();
assert.doesNotMatch(source, /<iframe/);
});
});
test('page bridge host is removed after native route-view decommission', () => {
const routerSource = readSource('src/portal-shell/router.js');
assert.doesNotMatch(routerSource, /PageBridgeView/);
const appPySource = readSource('../src/mes_dashboard/app.py');
assert.doesNotMatch(appPySource, /\/api\/portal\/wrapper-telemetry/);
});

View File

@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
function readSource(relativePath) {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8');
}
test('table parity: Wave B native pages keep deterministic column and empty-state handling', () => {
const jobSource = readSource('src/job-query/App.vue');
assert.match(jobSource, /jobsColumns/);
assert.match(jobSource, /txnColumns/);
assert.match(jobSource, /目前無資料/);
const excelSource = readSource('src/excel-query/App.vue');
assert.match(excelSource, /queryResult\.columns/);
assert.match(excelSource, /queryResult\.rows\.length === 0/);
const queryToolSource = readSource('src/query-tool/App.vue');
assert.match(queryToolSource, /resolvedColumns/);
assert.match(queryToolSource, /historyColumns/);
assert.match(queryToolSource, /associationColumns/);
assert.match(queryToolSource, /equipmentColumns/);
});
test('table parity: list/detail pages preserve pagination and sort continuity hooks', () => {
const wipDetailSource = readSource('src/wip-detail/App.vue');
assert.match(wipDetailSource, /const page = ref\(1\)/);
assert.match(wipDetailSource, /page_size|pageSize/);
const holdDetailSource = readSource('src/hold-detail/App.vue');
assert.match(holdDetailSource, /page|currentPage|perPage/);
assert.match(holdDetailSource, /distribution|lots/i);
const tmttTableSource = readSource('src/tmtt-defect/components/TmttDetailTable.vue');
assert.match(tmttTableSource, /sort/i);
});
test('chart parity: chart pages keep tooltip, legend, autoresize and click linkage', () => {
const qcChartSource = readSource('src/qc-gate/components/QcGateChart.vue');
assert.match(qcChartSource, /tooltip\s*:/);
assert.match(qcChartSource, /legend\s*:/);
assert.match(qcChartSource, /autoresize/);
assert.match(qcChartSource, /@click="handleChartClick"/);
const holdParetoSource = readSource('src/hold-history/components/ReasonPareto.vue');
assert.match(holdParetoSource, /tooltip\s*:/);
assert.match(holdParetoSource, /legend\s*:/);
assert.match(holdParetoSource, /@click="handleChartClick"/);
const tmttChartSource = readSource('src/tmtt-defect/components/TmttChartCard.vue');
assert.match(tmttChartSource, /tooltip\s*:/);
assert.match(tmttChartSource, /legend\s*:/);
assert.match(tmttChartSource, /autoresize/);
});
test('matrix interaction parity: selection/highlight/drill handlers remain present', () => {
const wipMatrixSource = readSource('src/wip-overview/components/MatrixTable.vue');
assert.match(wipMatrixSource, /emit\('drilldown'/);
const holdMatrixSource = readSource('src/hold-overview/components/HoldMatrix.vue');
assert.match(holdMatrixSource, /emit\('select'/);
assert.match(holdMatrixSource, /isCellActive|isRowActive|isColumnActive/);
const resourceMatrixSource = readSource('src/resource-status/components/MatrixSection.vue');
assert.match(resourceMatrixSource, /cell-filter/);
assert.match(resourceMatrixSource, /selectedColumns/);
assert.match(resourceMatrixSource, /toggle-all/);
});

View File

@@ -0,0 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildLaunchHref } from '../src/portal-shell/routeQuery.js';
import { toRuntimeRoute } from '../src/core/shell-navigation.js';
test('list-detail workflow preserves wip filters across launch href and shell runtime prefix', () => {
const detailHref = buildLaunchHref('/wip-detail', {
workcenter: 'WB12',
workorder: 'WO-001',
lotid: 'LOT-001',
status: 'queue',
});
assert.equal(
detailHref,
'/wip-detail?workcenter=WB12&workorder=WO-001&lotid=LOT-001&status=queue',
);
assert.equal(
toRuntimeRoute(detailHref, { currentPathname: '/portal-shell/wip-overview' }),
'/portal-shell/wip-detail?workcenter=WB12&workorder=WO-001&lotid=LOT-001&status=queue',
);
});
test('hold list-detail workflow keeps reason/workcenter/package query continuity', () => {
const holdDetailHref = buildLaunchHref('/hold-detail', {
reason: 'YieldLimit',
workcenter: 'DA',
package: 'QFN',
page: '2',
});
assert.equal(
holdDetailHref,
'/hold-detail?reason=YieldLimit&workcenter=DA&package=QFN&page=2',
);
assert.equal(
toRuntimeRoute(holdDetailHref, { currentPathname: '/portal-shell/hold-overview' }),
'/portal-shell/hold-detail?reason=YieldLimit&workcenter=DA&package=QFN&page=2',
);
});
test('resource history multi-value filters remain compatible in shell links', () => {
const historyHref = buildLaunchHref('/resource-history', {
start_date: '2026-02-01',
end_date: '2026-02-11',
workcenter_groups: ['DB', 'WB'],
families: ['DIP'],
resource_ids: ['EQ-01', 'EQ-02'],
granularity: 'day',
});
assert.ok(historyHref.includes('workcenter_groups=DB'));
assert.ok(historyHref.includes('workcenter_groups=WB'));
assert.ok(historyHref.includes('resource_ids=EQ-01'));
assert.ok(historyHref.includes('resource_ids=EQ-02'));
assert.equal(
toRuntimeRoute(historyHref, { currentPathname: '/portal-shell/resource' }),
`/portal-shell${historyHref}`,
);
});

View File

@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildLaunchHref } from '../src/portal-shell/routeQuery.js';
test('buildLaunchHref keeps base target route without query payload', () => {
assert.equal(buildLaunchHref('/job-query'), '/job-query');
});
test('buildLaunchHref appends scalar query values', () => {
assert.equal(
buildLaunchHref('/job-query', { q: 'ABCD', page: '2' }),
'/job-query?q=ABCD&page=2',
);
});
test('buildLaunchHref supports repeated query keys from array values', () => {
assert.equal(
buildLaunchHref('/excel-query', { lotid: ['L1', 'L2'], mode: 'upload' }),
'/excel-query?lotid=L1&lotid=L2&mode=upload',
);
});
test('buildLaunchHref replaces existing query keys with latest runtime values', () => {
assert.equal(
buildLaunchHref('/query-tool?mode=legacy&page=1', { mode: 'runtime', page: '3' }),
'/query-tool?mode=runtime&page=3',
);
});
test('buildLaunchHref ignores empty and null-like query values', () => {
assert.equal(
buildLaunchHref('/tmtt-defect', { start_date: '', end_date: null, shift: undefined }),
'/tmtt-defect',
);
});

View File

@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
function readSource(relativePath) {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8');
}
test('shell route view uses direct RouterView host (no transition blank-state)', () => {
const appSource = readSource('src/portal-shell/App.vue');
assert.match(appSource, /<RouterView \/>/);
assert.doesNotMatch(appSource, /<Transition name=\"route-fade\" mode=\"out-in\">/);
});
test('Wave A chart components keep autoresize and tooltip configuration', () => {
const chartFiles = [
'src/wip-overview/components/ParetoSection.vue',
'src/qc-gate/components/QcGateChart.vue',
'src/hold-history/components/DailyTrend.vue',
'src/hold-history/components/ReasonPareto.vue',
'src/hold-history/components/DurationChart.vue',
'src/resource-history/components/TrendChart.vue',
'src/resource-history/components/StackedChart.vue',
'src/resource-history/components/HeatmapChart.vue',
'src/resource-history/components/ComparisonChart.vue',
];
chartFiles.forEach((filePath) => {
const source = readSource(filePath);
assert.match(source, /tooltip\s*:/, `missing tooltip config: ${filePath}`);
assert.match(source, /autoresize/, `missing autoresize lifecycle hook: ${filePath}`);
});
});
test('QC Gate keeps linked chart-table interaction guards', () => {
const source = readSource('src/qc-gate/App.vue');
assert.match(source, /const activeFilter = ref\(null\)/);
assert.match(source, /const filteredLots = computed\(\(\) =>/);
assert.match(source, /function handleChartSelect\(filter\)/);
assert.match(source, /activeFilter\.value = null/);
});
test('resource tooltip lifecycle keeps resize listener cleanup', () => {
const source = readSource('src/resource-status/components/FloatingTooltip.vue');
assert.match(source, /window\.addEventListener\('resize', positionTooltip\)/);
assert.match(source, /window\.removeEventListener\('resize', positionTooltip\)/);
});

View File

@@ -0,0 +1,87 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { toRuntimeRoute } from '../src/core/shell-navigation.js';
import { getNativeModuleLoader } from '../src/portal-shell/nativeModuleRegistry.js';
import { buildDynamicNavigationState } from '../src/portal-shell/navigationState.js';
import { getRouteContract } from '../src/portal-shell/routeContracts.js';
const WAVE_A_ROUTES = Object.freeze([
'/wip-overview',
'/wip-detail',
'/hold-overview',
'/hold-detail',
'/hold-history',
'/resource',
'/resource-history',
'/qc-gate',
'/tmtt-defect',
]);
const WAVE_B_NATIVE_ROUTES = Object.freeze([
'/job-query',
'/excel-query',
'/query-tool',
]);
test('Wave A contracts resolve to native mode with native module loaders', () => {
WAVE_A_ROUTES.forEach((routePath) => {
const contract = getRouteContract(routePath);
assert.ok(contract, `missing contract: ${routePath}`);
assert.equal(contract.renderMode, 'native', `route mode mismatch: ${routePath}`);
assert.equal(typeof getNativeModuleLoader(routePath), 'function', `missing native loader: ${routePath}`);
});
});
test('Wave B contracts resolve to native mode with native module loaders after rewrite', () => {
WAVE_B_NATIVE_ROUTES.forEach((routePath) => {
const contract = getRouteContract(routePath);
assert.ok(contract, `missing contract: ${routePath}`);
assert.equal(contract.renderMode, 'native', `route mode mismatch: ${routePath}`);
assert.equal(typeof getNativeModuleLoader(routePath), 'function', `missing native loader: ${routePath}`);
});
});
test('Wave A routes register as native routes from navigation payload', () => {
const state = buildDynamicNavigationState(
[
{
id: 'reports',
name: 'Reports',
order: 1,
pages: WAVE_A_ROUTES.map((route, index) => ({
route,
name: route,
status: 'released',
order: index + 1,
})),
},
],
{ isAdmin: false },
);
assert.equal(state.dynamicRoutes.length, WAVE_A_ROUTES.length);
assert.deepEqual(state.diagnostics.missingContractRoutes, []);
assert.deepEqual(
state.dynamicRoutes.map((entry) => entry.renderMode),
Array(WAVE_A_ROUTES.length).fill('native'),
);
});
test('Wave A deep links preserve query string in shell runtime paths', () => {
const sampleDeepLinks = [
'/wip-overview?workorder=AA001&status=all',
'/wip-detail?workcenter=WB12&lotid=L01',
'/hold-overview?hold_type=quality&reason=QC',
'/hold-detail?reason=QC&workcenter=WB12',
'/hold-history?start_date=2026-02-01&end_date=2026-02-11&record_type=new,release',
'/resource-history?start_date=2026-02-01&end_date=2026-02-11&granularity=day',
];
sampleDeepLinks.forEach((routePath) => {
assert.equal(
toRuntimeRoute(routePath, { currentPathname: '/portal-shell/wip-overview' }),
`/portal-shell${routePath}`,
);
});
});

View File

@@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDynamicNavigationState } from '../src/portal-shell/navigationState.js';
import { buildLaunchHref } from '../src/portal-shell/routeQuery.js';
import { getRouteContract } from '../src/portal-shell/routeContracts.js';
const WAVE_B_NATIVE_CASES = Object.freeze([
{
route: '/job-query',
page: '設備維修查詢',
query: {
start_date: '2026-02-01',
end_date: '2026-02-11',
resource_ids: ['EQ-01', 'EQ-02'],
},
expectedParams: ['start_date=2026-02-01', 'end_date=2026-02-11', 'resource_ids=EQ-01', 'resource_ids=EQ-02'],
},
{
route: '/excel-query',
page: 'Excel 查詢工具',
query: {
mode: 'upload',
table_name: 'DWH.DW_MES_WIP',
search_column: 'LOT_ID',
},
expectedParams: ['mode=upload', 'table_name=DWH.DW_MES_WIP', 'search_column=LOT_ID'],
},
{
route: '/query-tool',
page: 'Query Tool',
query: {
input_type: 'lot_id',
values: ['GA23100020-A00-001'],
},
expectedParams: ['input_type=lot_id', 'values=GA23100020-A00-001'],
},
]);
test('Wave B routes use native mode after rewrite cutover', () => {
WAVE_B_NATIVE_CASES.forEach(({ route }) => {
const contract = getRouteContract(route);
assert.ok(contract, `missing contract for ${route}`);
assert.equal(contract.renderMode, 'native', `expected native mode for ${route}`);
assert.equal(contract.rollbackStrategy, 'fallback_to_legacy_route');
});
});
test('Wave B shell launch href keeps workflow query context', () => {
WAVE_B_NATIVE_CASES.forEach(({ route, query, expectedParams }) => {
const href = buildLaunchHref(route, query);
assert.ok(href.startsWith(route), `unexpected href path for ${route}: ${href}`);
expectedParams.forEach((token) => {
assert.ok(href.includes(token), `missing query token ${token} in ${href}`);
});
});
});
test('Wave B routes are registered as native targets from navigation payload', () => {
const state = buildDynamicNavigationState(
[
{
id: 'native-tools',
name: 'Native Tools',
order: 1,
pages: WAVE_B_NATIVE_CASES.map((entry, index) => ({
route: entry.route,
name: entry.page,
status: 'released',
order: index + 1,
})),
},
],
{ isAdmin: true },
);
assert.equal(state.dynamicRoutes.length, WAVE_B_NATIVE_CASES.length);
state.dynamicRoutes.forEach((entry) => {
assert.equal(entry.renderMode, 'native');
});
});

View File

@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
isPortalShellRuntime,
navigateToRuntimeRoute,
toRuntimeRoute,
} from '../src/core/shell-navigation.js';
test('isPortalShellRuntime detects portal-shell path prefix', () => {
assert.equal(isPortalShellRuntime('/portal-shell'), true);
assert.equal(isPortalShellRuntime('/portal-shell/wip-overview'), true);
assert.equal(isPortalShellRuntime('/wip-overview'), false);
});
test('toRuntimeRoute keeps legacy route outside shell runtime', () => {
assert.equal(
toRuntimeRoute('/wip-overview?status=run', { currentPathname: '/wip-overview' }),
'/wip-overview?status=run',
);
});
test('toRuntimeRoute prefixes target route inside shell runtime', () => {
assert.equal(
toRuntimeRoute('/wip-overview?status=run', { currentPathname: '/portal-shell/wip-overview' }),
'/portal-shell/wip-overview?status=run',
);
});
test('toRuntimeRoute avoids double-prefix for already-prefixed path', () => {
assert.equal(
toRuntimeRoute('/portal-shell/wip-overview', { currentPathname: '/portal-shell' }),
'/portal-shell/wip-overview',
);
});
test('navigateToRuntimeRoute uses shell router bridge in portal runtime', () => {
const originalWindow = globalThis.window;
const calls = [];
globalThis.window = {
location: {
pathname: '/portal-shell/wip-overview',
href: '/portal-shell/wip-overview',
replace: (value) => calls.push({ kind: 'replace-location', value }),
},
__MES_PORTAL_SHELL_NAVIGATE__: (target, options) => {
calls.push({ kind: 'bridge', target, options });
},
};
navigateToRuntimeRoute('/wip-detail?workcenter=WB12');
assert.deepEqual(calls, [
{
kind: 'bridge',
target: '/wip-detail?workcenter=WB12',
options: { replace: false },
},
]);
globalThis.window = originalWindow;
});
test('navigateToRuntimeRoute falls back to location when bridge is unavailable', () => {
const originalWindow = globalThis.window;
const calls = [];
globalThis.window = {
location: {
pathname: '/wip-overview',
href: '/wip-overview',
replace: (value) => calls.push({ kind: 'replace-location', value }),
},
};
navigateToRuntimeRoute('/wip-detail?workcenter=WB12');
assert.equal(globalThis.window.location.href, '/wip-detail?workcenter=WB12');
assert.deepEqual(calls, []);
globalThis.window = originalWindow;
});