import { expect, test, type Page, type Route, type TestInfo } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import {
ARGON_INTERACTION_CASES,
ARGON_ROUTE_CASES,
type ArgonInteractionCase,
} from './argonParityMatrix';
import {
captureAllRouteStates,
gotoLocalRoute,
waitForArgonPage,
writeArgonArtifact,
} from './argonParityHelpers';
interface PageFixture {
page: Page;
}
const BACKEND_BASE_CODEBLOCK_ARTICLE = {
authorName: 'KwiTsukasa',
categoriesResolved: [
{
name: 'public',
slug: 'public',
},
],
contentHtml: `
const a = 1;
const b = a + 1;
`,
date: '2026-07-01T16:40:00.000Z',
excerptText: '后端只生成基础 Argon 代码块,Blog Web 运行时补齐行号和控制条。',
id: 909001,
modified: '2026-07-01T16:40:00.000Z',
slug: 'backend-codeblock-runtime',
tagsResolved: [],
title: {
rendered: '后端代码块运行时',
},
};
/**
* Registers representative Argon interaction checks against the local Vue implementation.
*/
function registerLocalInteractionSuite() {
for (const interactionCase of ARGON_INTERACTION_CASES) {
test(`local interaction surface exists: ${interactionCase.key}`, createInteractionSurfaceTest(interactionCase));
}
test('local settings panel mutates Argon theme state', createSettingsMutationTest());
test('local search flow reaches a result route', createSearchFlowTest());
test('local taxonomy modals expose captured category and tag chips', createTaxonomyModalTest());
test('local blog modal delegates motion to antdv-next while keeping Blog theme tokens', createBlogModalThemeMotionSourceTest());
test('local Blog animation and DOM contracts are centralized in factories', createBlogFactoryContractSourceTest());
test('local mobile sidebar toggles like Argon leftbar', createMobileSidebarTest());
test('local post page exposes share, comments, and reading progress', createPostInteractionTest());
test('local post codeblocks match Argon hljs layout', createPostCodeblockSurfaceTest());
test('local post codeblock controls match Argon runtime toggles', createPostCodeblockControlBehaviorTest());
test('local post upgrades backend-generated base Argon codeblocks at runtime', createBackendBaseCodeblockRuntimeTest());
test('local post codeblock copy exposes Argon toast feedback', createPostCodeblockCopyFeedbackTest());
test('local post links match live WordPress Argon inline semantics', createPostInlineLinkSurfaceTest());
test('local post fancybox wrappers open live Argon lightbox shell', createPostFancyboxRuntimeTest());
test('local post image placeholders match Argon lazyload geometry', createPostImagePlaceholderSurfaceTest());
test('local post separators match live WordPress Argon surface', createPostSeparatorSurfaceTest());
test('local header container matches live Argon navbar geometry', createHeaderContainerSurfaceTest());
test('local sidebar menu items match live Argon menu surface and admin target', createSidebarMenuSurfaceTest());
test('local sidebar overview keeps Argon tab-pane structure without archive navigation', createSidebarOverviewSurfaceTest());
test('local post sidebar tabs switch catalog and overview panes', createPostSidebarTabTest());
test('local post sidebar tabs fade between catalog and overview panes', createPostSidebarTabFadeTest());
test('local post sidebar tab fade keeps Argon class-frame timing', createPostSidebarClassFrameSourceTest());
test('local replaceable Argon DOM behavior avoids native id selectors', createReplaceableDomRefSourceTest());
test('local sidebar search button matches Argon button interaction surface', createSidebarSearchButtonTest());
test('local floating action buttons match Argon fixed surface and filter positioning', createFloatingActionSurfaceTest());
test('local post share button matches Argon primary hover surface', createPostShareButtonSurfaceTest());
test('local post navigation matches Argon card-body-free layout', createPostNavigationSurfaceTest());
test('local rightbar archive links preserve month targets', createRightbarArchiveLinkTest());
test('local article card link hover and target semantics match Argon', createArticleCardLinkInteractionTest());
}
/**
* @param interactionCase Interaction selector captured from the live Argon surface.
* @returns Playwright test body that verifies the same local control is present and actionable.
*/
function createInteractionSurfaceTest(interactionCase: ArgonInteractionCase) {
/**
* @param fixtures Playwright page fixture used to open the local home page.
*/
return async function runInteractionSurfaceTest({ page }: PageFixture) {
await gotoLocalRoute(page, getInteractionRouteCase(interactionCase));
await prepareInteractionSurface(page, interactionCase);
const selector = interactionCase.selector || interactionCase.key;
const control = page.locator(selector).first();
await expect(control).toBeVisible();
await expect(control).toBeEnabled();
};
}
/**
* @param interactionCase Interaction key from the Argon capture classification.
* @returns Route where the control naturally exists in the local Vue app.
*/
function getInteractionRouteCase(interactionCase: ArgonInteractionCase) {
if (
interactionCase.key === '#share_show' ||
interactionCase.key === '#fabtn_reading_progress' ||
interactionCase.key === '#post_comment_content' ||
interactionCase.key === '#post_comment_send'
) {
return ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'post-50')!;
}
if (interactionCase.key.startsWith('#search_filter_')) {
return ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'search-NAS')!;
}
return ARGON_ROUTE_CASES[0];
}
/**
* @param page Browser page currently opened on the route returned by `getInteractionRouteCase`.
* @param interactionCase Interaction whose natural visible state may require scrolling.
*/
async function prepareInteractionSurface(page: Page, interactionCase: ArgonInteractionCase) {
if (interactionCase.key === '#open_sidebar' || interactionCase.key === '#sidebar_mask') {
await page.setViewportSize({ height: 844, width: 390 });
return;
}
if (interactionCase.key === '#fabtn_back_to_top' || interactionCase.key === '#fabtn_reading_progress') {
await page.evaluate(() => window.scrollTo(0, Math.round(document.documentElement.scrollHeight * 0.45)));
await page.waitForTimeout(220);
}
}
/**
* @returns Playwright test body that opens settings and verifies theme class/CSS-variable changes.
*/
function createSettingsMutationTest() {
/**
* @param fixtures Playwright page fixture used to mutate local Blog theme controls.
* @param testInfo Metadata used to persist post-mutation snapshots under `.kt-workspace`.
*/
return async function runSettingsMutationTest({ page }: PageFixture, testInfo: TestInfo) {
const homeRoute = ARGON_ROUTE_CASES[0];
await gotoLocalRoute(page, homeRoute);
await page.locator('#fabtn_toggle_blog_settings_popup').click();
await expect(page.locator('#blog_settings_popup, .kt-blog__settings-panel')).toBeVisible();
await page.locator('#blog_setting_font_serif').click();
await expect(page.locator('.kt-blog')).toHaveClass(/kt-blog--font-serif/);
await page.locator('#blog_setting_shadow_big').click();
await expect(page.locator('.kt-blog')).toHaveClass(/kt-blog--shadow-big/);
await page.locator('#blog_setting_filter_grayscale').click();
await expect(page.locator('html')).toHaveClass(/filter-grayscale/);
await expect(page.locator('.kt-blog')).not.toHaveClass(/kt-blog--filter-grayscale/);
await writeArgonArtifact(testInfo, 'settings-mutated', await captureAllRouteStates(page, homeRoute));
};
}
/**
* @returns Playwright test body that covers top/left search semantics and no-result state.
*/
function createSearchFlowTest() {
/**
* @param fixtures Playwright page fixture used to submit a local Argon search.
* @param testInfo Metadata used to persist search-route snapshots under `.kt-workspace`.
*/
return async function runSearchFlowTest({ page }: PageFixture, testInfo: TestInfo) {
const searchRoute = ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'search-NAS')!;
await gotoLocalRoute(page, ARGON_ROUTE_CASES[0]);
await page.locator('#leftbar_search_container').click();
await page.locator('#leftbar_search_input').fill('NAS');
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/\/#\/search\?q=NAS/);
await expect(page.getByText('NAS', { exact: false }).first()).toBeVisible();
await writeArgonArtifact(testInfo, 'search-NAS', await captureAllRouteStates(page, searchRoute));
};
}
/**
* @returns Playwright test body that checks the leftbar search control id belongs to the visible button, not a wrapper.
*/
function createSidebarSearchButtonTest() {
/**
* @param fixtures Playwright page fixture used to inspect leftbar search behavior.
*/
return async function runSidebarSearchButtonTest({ page }: PageFixture) {
await gotoLocalRoute(page, ARGON_ROUTE_CASES[0]);
const searchButton = page.locator('#leftbar_search_container');
await expect(searchButton).toHaveJSProperty('tagName', 'BUTTON');
await expect(searchButton).toBeVisible();
await expect(searchButton).toHaveCSS('cursor', 'pointer');
const buttonBox = await searchButton.boundingBox();
expect(Math.round(buttonBox?.height ?? 0)).toBe(30);
expect(Math.round(buttonBox?.width ?? 0)).toBeLessThan(230);
await searchButton.click();
const searchInput = page.locator('#leftbar_search_input input, input#leftbar_search_input').first();
await expect(searchInput).toBeFocused();
};
}
/**
* @returns Playwright test body that checks Argon's fixed floating button geometry and filter stability.
*/
function createFloatingActionSurfaceTest() {
/**
* @param fixtures Playwright page fixture used to inspect floating button and settings panel geometry.
*/
return async function runFloatingActionSurfaceTest({ page }: PageFixture) {
await page.setViewportSize({ height: 900, width: 1440 });
await gotoLocalRoute(page, ARGON_ROUTE_CASES[0]);
const actions = page.locator('.kt-blog__float-actions');
const settingsButton = page.locator('#fabtn_toggle_blog_settings_popup');
const filterButton = page.locator('#blog_setting_filter_grayscale');
const settingsPanel = page.locator('#blog_settings_popup');
const themeRoot = page.locator('.kt-blog');
await expect(actions).toHaveCSS('position', 'fixed');
await expect(actions).toHaveCSS('right', '20px');
await expect(actions).toHaveCSS('bottom', '35px');
await expect(actions).toHaveCSS('z-index', '1000');
await expect(actions).toHaveCSS('pointer-events', 'auto');
await expectViewportOffset(actions, { bottom: 35, right: 20 });
await expect(settingsButton).toBeVisible();
await expectRect(settingsButton, { height: 42, width: 42 });
await expect(settingsButton).toHaveCSS('padding', '0px');
await expect(settingsButton).toHaveCSS('transform', 'none');
await settingsButton.hover();
await expect(settingsButton).toHaveCSS('transform', 'none');
await settingsButton.click();
await expect(settingsPanel).toBeVisible();
await expectViewportOffset(settingsPanel, { bottom: 35, right: 85 });
await expectRect(filterButton, { height: 50, width: 50 });
await expect(filterButton).toHaveCSS('background-color', 'rgba(200, 200, 200, 0.8)');
await expect(filterButton).toHaveCSS('white-space', 'nowrap');
await filterButton.click();
await expect(page.locator('html')).toHaveClass(/filter-grayscale/);
await expect(themeRoot).toHaveCSS('filter', 'none');
await expect(page.locator('html')).toHaveCSS('filter', 'grayscale(1)');
await expectViewportOffset(actions, { bottom: 35, right: 20 });
await expectViewportOffset(settingsPanel, { bottom: 35, right: 85 });
await page.evaluate(() => window.scrollTo(0, 700));
await page.waitForTimeout(250);
await expectViewportOffset(actions, { bottom: 35, right: 20 });
await expectViewportOffset(settingsPanel, { bottom: 35, right: 85 });
};
}
/**
* @returns Playwright test body that verifies the post share opener keeps Argon's button dimensions and hover motion.
*/
function createPostShareButtonSurfaceTest() {
/**
* @param fixtures Playwright page fixture used to inspect the post share button.
*/
return async function runPostShareButtonSurfaceTest({ page }: PageFixture) {
const postRoute = ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'post-61')!;
await gotoLocalRoute(page, postRoute);
const shareButton = page.locator('#share_show');
await expect(shareButton).toBeVisible();
const buttonBox = await shareButton.boundingBox();
expect(Math.round(buttonBox?.width ?? 0)).toBe(50);
expect(Math.round(buttonBox?.height ?? 0)).toBe(36);
await shareButton.hover();
await expect.poll(async () => shareButton.evaluate((element) => getComputedStyle(element).transform)).not.toBe('none');
};
}
/**
* @returns Playwright test body that verifies post previous/next navigation keeps Argon's native structure.
*/
function createPostNavigationSurfaceTest() {
/**
* @param fixtures Playwright page fixture used to inspect a post with both previous and next links.
*/
return async function runPostNavigationSurfaceTest({ page }: PageFixture) {
const postRoute = ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'post-50')!;
await page.setViewportSize({ height: 900, width: 1440 });
await gotoLocalRoute(page, postRoute);
const navigation = page.locator('.post-navigation');
const previousItem = page.locator('.post-navigation-pre');
const nextItem = page.locator('.post-navigation-next');
const previousLabel = previousItem.locator('.page-navigation-extra-text');
const nextLabel = nextItem.locator('.page-navigation-extra-text');
const previousLink = previousItem.locator('a');
await expect(navigation).toHaveCSS('padding', '25px 25px 30px');
await expect(navigation).toHaveCSS('min-height', '0px');
await expect(navigation).toHaveCSS('position', 'relative');
await expect(navigation).toHaveCSS('border-top-width', '1px');
await expectRect(navigation, { height: 132 });
await expect(page.locator('.post-navigation > .post-navigation-item')).toHaveCount(2);
await expect(previousItem).toHaveCSS('min-height', '0px');
await expect(nextItem).toHaveCSS('min-height', '0px');
await expect(previousItem).toHaveCSS('display', 'inline-block');
await expect(nextItem).toHaveCSS('text-align', 'right');
await expect(previousLabel).toHaveCSS('font-size', '22px');
await expect(previousLabel).toHaveCSS('line-height', '33px');
await expect(previousLabel).toHaveCSS('margin-bottom', '15px');
await expect(previousLabel).toHaveCSS('opacity', '0.85');
await expect(previousLabel.locator('i')).toHaveCSS('margin-right', '10px');
await expect(nextLabel.locator('i')).toHaveCSS('margin-left', '10px');
await expect(page.locator('#comments > .card-body')).toBeVisible();
await expect(page.locator('#post_comment > .card-body')).toBeVisible();
await expectLeftEdgesToMatch(previousItem, previousLink);
};
}
/**
* @returns Playwright test body that verifies rightbar archive links keep the captured month filter.
*/
function createRightbarArchiveLinkTest() {
/**
* @param fixtures Playwright page fixture used to inspect rightbar archive hrefs.
*/
return async function runRightbarArchiveLinkTest({ page }: PageFixture) {
await gotoLocalRoute(page, ARGON_ROUTE_CASES[0]);
const juneArchive = page.locator('#rightbar').getByRole('link', { name: '2026 年 6 月' });
await expect(juneArchive).toBeVisible();
await expect(juneArchive).toHaveAttribute('href', /month=202606/);
};
}
/**
* @returns Playwright test body that verifies article title/category/tag link interaction parity.
*/
function createArticleCardLinkInteractionTest() {
/**
* @param fixtures Playwright page fixture used to inspect the first article card.
*/
return async function runArticleCardLinkInteractionTest({ page }: PageFixture) {
await gotoLocalRoute(page, ARGON_ROUTE_CASES[0]);
const title = page.locator('.kt-blog__post-title').first();
const titleColorBefore = await title.evaluate((element) => getComputedStyle(element).color);
await title.hover();
await expect.poll(async () => title.evaluate((element) => getComputedStyle(element).color)).not.toBe(titleColorBefore);
const category = page.locator('.kt-blog__post-meta a').first();
await expect(category).toHaveAttribute('target', '_blank');
const tag = page.locator('.kt-blog__post-tags a').first();
await expect(tag).toHaveAttribute('target', '_blank');
await expect(tag).toHaveCSS('font-weight', '600');
};
}
/**
* @returns Playwright test body that keeps modal motion simple and delegates open/close animation to antdv-next.
*/
function createBlogModalThemeMotionSourceTest() {
return async function runBlogModalThemeMotionSourceTest() {
const modalSource = await readFile(new URL('../../src/components/blog/BlogModal.tsx', import.meta.url), 'utf8');
const modalStyle = await readFile(new URL('../../src/styles/modal.scss', import.meta.url), 'utf8');
expect(modalSource).not.toMatch(/\bTransition\b/);
expect(modalSource).not.toContain('kt-blog__modal-transition-root');
expect(modalSource).not.toContain('footer={null}');
expect(modalSource).toContain('kt-blog__modal-header');
expect(modalSource).toContain('kt-blog__modal-content');
expect(modalSource).toContain('kt-blog__modal-footer');
expect(modalSource).toContain('footer={modalFooter}');
expect(modalSource).toContain('slots.footer');
expect(modalStyle).not.toContain('__modal-enter-active');
expect(modalStyle).not.toContain('__modal-leave-active');
expect(modalStyle).not.toContain('padding: 18px 22px');
expect(modalStyle).not.toContain('padding: 20px 22px 24px');
expect(modalStyle).not.toContain('__taxonomy-modal &__modal-body');
expect(modalStyle).not.toContain('&__taxonomy-modal .ant-modal-body');
expect(modalSource).toContain('centered');
expect(modalStyle).toContain('max-height: min(80vh, calc(100vh - 32px))');
expect(modalStyle).toContain('flex-direction: column');
expect(modalStyle).toContain('padding: 0');
expect(modalStyle).toContain('padding: 16px 20px');
expect(modalStyle).toContain('min-height: 160px');
expect(modalStyle).toContain('padding: 12px');
expect(modalStyle).toContain('padding: 8px');
expect(modalStyle).toContain('overflow-y: auto');
expect(modalStyle).toContain('background: var(--color-widgets)');
expect(modalStyle).toContain('color: var(--argon-text)');
expect(modalStyle).toContain('background: var(--themecolor)');
};
}
/**
* @returns Playwright source-level test body that keeps Argon animation timing and DOM/ref ids routed through factories.
*/
function createBlogFactoryContractSourceTest() {
return async function runBlogFactoryContractSourceTest() {
const animationFactory = await readFile(new URL('../../src/factories/blogAnimationFactory.ts', import.meta.url), 'utf8');
const domFactory = await readFile(new URL('../../src/factories/blogDomFactory.ts', import.meta.url), 'utf8');
const sidebarSource = await readFile(new URL('../../src/components/blog/BlogSidebar.tsx', import.meta.url), 'utf8');
const layoutSource = await readFile(new URL('../../src/components/blog/BlogLayout.tsx', import.meta.url), 'utf8');
const floatActionsSource = await readFile(new URL('../../src/components/blog/BlogFloatActions.tsx', import.meta.url), 'utf8');
const shareSource = await readFile(new URL('../../src/components/blog/BlogShare.tsx', import.meta.url), 'utf8');
const postPageSource = await readFile(new URL('../../src/views/blog/PostPage.tsx', import.meta.url), 'utf8');
const argonEffectsSource = await readFile(new URL('../../src/hooks/useArgonEffects.ts', import.meta.url), 'utf8');
const domRefsSource = await readFile(new URL('../../src/hooks/useBlogDomRefs.ts', import.meta.url), 'utf8');
expect(animationFactory).toContain('BLOG_ANIMATION_TIMING_MS');
expect(animationFactory).toContain('BLOG_SCROLL_GEOMETRY');
expect(animationFactory).toContain('createBlogFrameScheduler');
expect(animationFactory).toContain('requestBlogFrame');
expect(domFactory).toContain('BLOG_DOM_IDS');
expect(domFactory).toContain('blogDomId');
expect(domFactory).toContain('blogDomAnchor');
expect(domFactory).toContain('createBlogElementRef');
for (const source of [
sidebarSource,
layoutSource,
floatActionsSource,
shareSource,
postPageSource,
argonEffectsSource,
domRefsSource,
]) {
expect(`${source}\n`).toMatch(/@\/factories\/blog(?:Animation|Dom)Factory/);
}
expect(sidebarSource).not.toMatch(/const ARGON_CATALOG_(?:MANUAL_LOCK_MS|OFFSET|SCROLL_EXTRA|NORMAL_ANIMATION_MS)\s*=/);
expect(sidebarSource).not.toContain('window.requestAnimationFrame');
expect(sidebarSource).not.toContain('setTimeout(() =>');
expect(layoutSource).not.toMatch(/id="(?:banner|banner_container|content|sidebar_mask|primary|main|footer)"/);
expect(floatActionsSource).not.toMatch(/id="(?:fabtn_toggle_sides|fabtn_back_to_top|fabtn_toggle_blog_settings_popup|fabtn_reading_progress|blog_settings_popup)"/);
expect(floatActionsSource).not.toContain('}, 300)');
expect(shareSource).not.toMatch(/id="(?:share_container|share_show|share)"/);
expect(postPageSource).not.toMatch(/id="(?:post_content|comments|post_comment|post_comment_content|post_comment_send)"/);
expect(argonEffectsSource).not.toContain('duration = 800');
expect(argonEffectsSource).not.toContain('scrollTop < 30');
expect(domRefsSource).toContain('createBlogElementRef');
};
}
/**
* @returns Playwright test body that checks sidebar stats open local taxonomy modals with captured terms.
*/
function createTaxonomyModalTest() {
/**
* @param fixtures Playwright page fixture used to open and close taxonomy modals.
*/
return async function runTaxonomyModalTest({ page }: PageFixture) {
await gotoLocalRoute(page, ARGON_ROUTE_CASES[0]);
await page.locator('[data-argon-taxonomy="categories"]').click();
const categoriesModal = page.locator('.kt-blog__taxonomy-modal--categories');
const categoriesPanel = categoriesModal.locator('.ant-modal');
const categoriesMask = categoriesModal.locator('.ant-modal-mask');
await expect(categoriesModal.locator('.kt-blog__modal-wrap')).toBeVisible();
await expectRect(categoriesPanel, { width: 500 });
await expect.poll(() => categoriesPanel.evaluate((element) => getComputedStyle(element).transform)).toBe('none');
const categoriesPanelBox = await categoriesPanel.boundingBox();
const expectedCenteredTop = (page.viewportSize()!.height - (categoriesPanelBox?.height ?? 0)) / 2;
expect(Math.abs((categoriesPanelBox?.y ?? 0) - expectedCenteredTop)).toBeLessThanOrEqual(16);
const categoriesContent = categoriesModal.locator('.ant-modal-container');
const categoriesBody = categoriesModal.locator('.ant-modal-body');
const categoriesFooter = categoriesModal.locator('.ant-modal-footer');
await expect(categoriesModal.locator('.ant-modal-header .kt-blog__modal-header')).toBeVisible();
await expect(categoriesModal.locator('.ant-modal-body .kt-blog__modal-content')).toBeVisible();
await expect(categoriesFooter).toHaveCount(0);
await expect(categoriesModal.locator('.ant-modal-header')).toHaveCSS('padding', '16px 20px');
await expect(categoriesContent).toHaveCSS('padding', '0px');
await expect(categoriesModal.locator('.ant-modal-header')).toHaveCSS('width', '500px');
await expect(categoriesBody).toHaveCSS('width', '500px');
await expect(categoriesBody).toHaveCSS('min-height', '160px');
await expect(categoriesBody).toHaveCSS('padding', '12px');
await expect(categoriesContent).toHaveCSS('display', 'flex');
await expect(categoriesContent).toHaveCSS('flex-direction', 'column');
const modalContentMaxHeight = await categoriesContent.evaluate((element) => {
const maxHeight = getComputedStyle(element).maxHeight;
return maxHeight.endsWith('px') ? Number.parseFloat(maxHeight) : 0;
});
expect(modalContentMaxHeight).toBeLessThanOrEqual(page.viewportSize()!.height * 0.8);
await expect(categoriesBody).toHaveCSS('overflow-y', 'auto');
await expect(categoriesMask).toHaveCSS('background-color', 'rgba(0, 0, 0, 0.16)');
await expect(categoriesModal.locator('.kt-blog__tag').first()).toHaveCSS('height', '26px');
await expect(categoriesModal.locator('.kt-blog__tag').first()).toHaveCSS('padding', '5px 10px');
const categoryChips = await categoriesModal.locator('.kt-blog__tag').evaluateAll((items) =>
items.map((item) => item.textContent?.replace(/\s+/g, ' ').trim()).filter(Boolean),
);
expect(categoryChips).toEqual(['MQTT 1', 'NAS 3', 'Node 1', 'public 1', 'Vue 2']);
await page.mouse.click(20, 20);
await expect(categoriesModal.locator('.kt-blog__modal-wrap')).toBeHidden();
await page.locator('[data-argon-taxonomy="categories"]').click();
await page.keyboard.press('Escape');
await expect(categoriesModal.locator('.kt-blog__modal-wrap')).toBeHidden();
await page.locator('[data-argon-taxonomy="tags"]').click();
const tagsModal = page.locator('.kt-blog__taxonomy-modal--tags');
await expect(tagsModal.locator('.kt-blog__modal-wrap')).toBeVisible();
const tagLabels = await tagsModal.locator('.kt-blog__tag').evaluateAll((items) =>
items.map((item) => item.textContent?.replace(/\d+$/u, '').trim()).filter(Boolean),
);
expect(tagLabels).toEqual(['MQTT', 'NAS', 'Node', 'Vue']);
await expect(tagsModal).not.toContainText('NestJS');
await page.keyboard.press('Escape');
await expect(tagsModal.locator('.kt-blog__modal-wrap')).toBeHidden();
};
}
/**
* @param locator Element whose rounded bounding box should match captured live geometry.
* @param expected Expected geometry fields; omitted fields are not checked.
*/
async function expectRect(
locator: ReturnType,
expected: Partial<{ height: number; width: number; x: number; y: number }>,
) {
for (const [field, value] of Object.entries(expected) as Array<[keyof typeof expected, number]>) {
await expect
.poll(
async () => {
const box = await locator.boundingBox();
return Math.round(box?.[field] ?? 0);
},
{ message: `rect.${field}` },
)
.toBe(value);
}
}
/**
* @param locator Element whose pseudo-element computed style should be read from the browser.
* @param pseudo CSS pseudo-element name such as `::before`.
* @param property Camel-case computed style property to read.
* @returns Browser-computed pseudo-element style value.
*/
async function readPseudoStyle(
locator: ReturnType,
pseudo: '::after' | '::before',
property: keyof CSSStyleDeclaration,
) {
return locator.evaluate(
(element, args) => String(getComputedStyle(element, args.pseudo)[args.property]),
{ property, pseudo },
);
}
/**
* @param locator Element whose viewport offsets should stay fixed like the live Argon controls.
* @param expected Expected right/bottom distance from the layout viewport edge in pixels.
*/
async function expectViewportOffset(
locator: ReturnType,
expected: Partial<{ bottom: number; right: number }>,
) {
for (const [field, value] of Object.entries(expected) as Array<[keyof typeof expected, number]>) {
await expect
.poll(
async () =>
locator.evaluate((element, offsetField) => {
const rect = element.getBoundingClientRect();
return Math.round(
offsetField === 'right'
? document.documentElement.clientWidth - rect.right
: document.documentElement.clientHeight - rect.bottom,
);
}, field),
{ message: `viewport.${field}` },
)
.toBe(value);
}
}
/**
* @param reference Element whose left edge is the expected Argon alignment anchor.
* @param target Element whose left edge should align to the same x coordinate.
*/
async function expectLeftEdgesToMatch(
reference: ReturnType,
target: ReturnType,
) {
await expect
.poll(async () => {
const [referenceBox, targetBox] = await Promise.all([
reference.boundingBox(),
target.boundingBox(),
]);
return Math.round((targetBox?.x ?? 0) - (referenceBox?.x ?? 0));
}, { message: 'rect.leftDelta' })
.toBe(0);
}
/**
* @returns Playwright test body that validates the mobile Argon leftbar open and mask close flow.
*/
function createMobileSidebarTest() {
/**
* @param fixtures Playwright page fixture used to check mobile-only sidebar geometry.
*/
return async function runMobileSidebarTest({ page }: PageFixture) {
await page.setViewportSize({ height: 844, width: 390 });
await gotoLocalRoute(page, ARGON_ROUTE_CASES[0]);
await expect(page.locator('#open_sidebar')).toBeVisible();
await expect(page.locator('html')).not.toHaveClass(/leftbar-opened/);
await expect(page.locator('#leftbar')).toHaveCSS('left', '-300px');
await page.locator('#open_sidebar').click();
await expect(page.locator('html')).toHaveClass(/leftbar-opened/);
await expect(page.locator('#leftbar')).toHaveCSS('left', '0px');
await expect(page.locator('#sidebar_mask')).toHaveCSS('opacity', '1');
await page.mouse.click(370, 120);
await expect(page.locator('html')).not.toHaveClass(/leftbar-opened/);
await expect(page.locator('#leftbar')).toHaveCSS('left', '-300px');
};
}
/**
* @returns Playwright test body that validates post-only controls seen in the live Argon capture.
*/
function createPostInteractionTest() {
/**
* @param fixtures Playwright page fixture used to open a representative post page.
* @param testInfo Metadata used to persist post interaction snapshots under `.kt-workspace`.
*/
return async function runPostInteractionTest({ page }: PageFixture, testInfo: TestInfo) {
const postRoute = ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'post-50')!;
await gotoLocalRoute(page, postRoute);
await page.evaluate(() => window.scrollTo(0, Math.round(document.documentElement.scrollHeight * 0.45)));
await page.waitForTimeout(220);
await expect(page.locator('#fabtn_reading_progress')).toBeVisible();
await page.locator('#share_show').click();
await expect(page.locator('#share, .kt-blog__share')).toBeVisible();
await expect(page.locator('#share_copy_link')).toBeVisible();
await expect(page.locator('#share_show')).toHaveCSS('opacity', '0');
await expect(page.locator('.kt-blog__share-link')).toHaveCount(9);
await page.locator('#post_comment_content, .kt-blog__comment-textarea').first().fill('Argon parity smoke');
await expect(page.locator('#post_comment_send, .kt-blog__comment-submit').first()).toBeVisible();
await expect(page.locator('#post_comment_captcha')).toBeVisible();
await expect(page.locator('#post_comment_link')).toHaveCount(0);
await expect(page.locator('#post_comment_toggle_extra_input')).toHaveCount(0);
await expect(page.locator('#comment_post_use_markdown')).toBeVisible();
await expect(page.locator('#comment_emotion_btn')).toBeVisible();
await expect(page.locator('#post_comment form')).toHaveClass(/ant-form/);
await expect(page.locator('#post_comment_content')).toHaveClass(/ant-input/);
await expectVisibleFontAwesomeIcon(page, '#comment_emotion_btn i');
await expectVisibleFontAwesomeIcon(page, '#post_comment_send i');
await expect(page.locator('#comment_emotion_btn')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(page.locator('#comment_emotion_btn')).toHaveCSS('box-shadow', 'none');
await expect(page.locator('#comment_emotion_btn i')).toHaveCSS('font-size', '25px');
await expectRect(page.locator('#post_comment_content'), { height: 80 });
await expectRect(page.locator('#post_comment_name'), { height: 46 });
await expectRect(page.locator('#post_comment_send'), { height: 43, width: 100 });
await expect(page.locator('#post_comment_send')).toHaveCSS('border-radius', '4px');
await writeArgonArtifact(testInfo, 'post-50-interactions', await captureAllRouteStates(page, postRoute));
};
}
/**
* @returns Playwright test body that verifies WordPress separator blocks use the live Argon divider surface.
*/
function createPostSeparatorSurfaceTest() {
/**
* @param fixtures Playwright page fixture used to inspect a post containing real WordPress separator blocks.
*/
return async function runPostSeparatorSurfaceTest({ page }: PageFixture) {
const postRoute = ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'post-46')!;
await gotoLocalRoute(page, postRoute);
const separator = page.locator('#post_content hr.wp-block-separator').first();
await expect(separator).toHaveCount(1);
await expect(separator).toHaveCSS('display', 'block');
await expect(separator).toHaveCSS('margin', '32px 0px');
await expect(separator).toHaveCSS('border-top-width', '1px');
await expect(separator).toHaveCSS('border-top-style', 'solid');
await expect(separator).toHaveCSS('border-top-color', 'rgb(71, 63, 82)');
await expect(separator).toHaveCSS('border-bottom-width', '0px');
await expectRect(separator, { height: 1 });
};
}
/**
* @returns Playwright test body that checks WordPress lazyload image placeholders keep live Argon dimensions.
*/
function createPostImagePlaceholderSurfaceTest() {
/**
* @param fixtures Playwright page fixture used to inspect a post containing WordPress image figures.
*/
return async function runPostImagePlaceholderSurfaceTest({ page }: PageFixture) {
const postRoute = ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'post-20')!;
await gotoLocalRoute(page, postRoute);
const figure = page.locator('#post_content figure.wp-block-image').first();
const wrapper = figure.locator('.fancybox-wrapper').first();
const image = figure.locator('img.lazyload').first();
await expect(figure).toHaveCSS('margin', '48px 0px');
await expectRect(figure, { height: 500 });
await expectRect(wrapper, { height: 500 });
await expectRect(image, { height: 500 });
await expect(image).toHaveCSS('display', 'inline');
await expect(image).toHaveCSS('vertical-align', 'bottom');
await expect(image).toHaveCSS('background-color', 'rgb(60, 54, 67)');
};
}
/**
* @returns Playwright test body that verifies Argon's codeblock control buttons mutate the same runtime classes.
*/
function createPostCodeblockControlBehaviorTest() {
/**
* @param fixtures Playwright page fixture used to inspect and click rendered highlight.js controls.
*/
return async function runPostCodeblockControlBehaviorTest({ page }: PageFixture) {
const postRoute = ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'post-61')!;
await gotoLocalRoute(page, postRoute);
const pre = page.locator('#post_content pre.hljs-codeblock, #post_content pre.wp-block-code.hljs-codeblock').first();
const code = pre.locator('code').first();
const lineNumber = pre.locator('.hljs-ln-numbers').first();
const lineCode = pre.locator('.hljs-ln-code').first();
await pre.locator('.hljs-control-toggle-break-line').click();
await expect(pre).toHaveClass(/hljs-break-line/);
await expect(code).toHaveCSS('white-space', 'pre');
await expect(lineCode).toHaveCSS('white-space', 'break-spaces');
await expect(lineCode).toHaveCSS('line-break', 'anywhere');
expect(
await pre
.locator('.hljs-control-toggle-break-line')
.evaluate((element) => getComputedStyle(element, '::before').content),
).toBe('"关闭折行"');
await pre.locator('.hljs-control-toggle-linenumber').click();
await expect(pre).toHaveClass(/hljs-hide-linenumber/);
await expect(lineNumber).toHaveCSS('display', 'block');
await expect(lineNumber).toHaveCSS('opacity', '0');
await expect(lineCode).toHaveCSS('padding-left', '4px');
expect(
await pre
.locator('.hljs-control-toggle-linenumber')
.evaluate((element) => getComputedStyle(element, '::before').content),
).toBe('"显示行号"');
await pre.locator('.hljs-control-fullscreen').click();
await expect(pre).toHaveClass(/hljs-codeblock-fullscreen/);
await expect(page.locator('html')).toHaveClass(/codeblock-fullscreen/);
await expect(pre).toHaveCSS('position', 'fixed');
await expect(pre).toHaveCSS('z-index', '10000');
await expect(pre).toHaveCSS('padding', '0px');
await expect(code).toHaveCSS('position', 'absolute');
await expect(code).toHaveCSS('border-radius', '0px');
expect(
await pre
.locator('.hljs-control-fullscreen')
.evaluate((element) => getComputedStyle(element, '::before').content),
).toBe('"退出全屏"');
await pre.locator('.hljs-control-fullscreen').click();
await expect(pre).not.toHaveClass(/hljs-codeblock-fullscreen/);
await expect(page.locator('html')).not.toHaveClass(/codeblock-fullscreen/);
};
}
/**
* @returns Playwright test body that verifies backend-generated base codeblocks are upgraded on page bind.
*/
function createBackendBaseCodeblockRuntimeTest() {
/**
* @param fixtures Playwright page fixture used to intercept the public article API and render a base codeblock.
*/
return async function runBackendBaseCodeblockRuntimeTest({ page }: PageFixture) {
await page.route('**/api/blog/article/public/list**', fulfillBackendBaseCodeblockArticleList);
await page.route('**/api/blog/article/public/detail**', fulfillBackendBaseCodeblockArticleDetail);
await page.goto('/#/post/backend-codeblock-runtime', { waitUntil: 'domcontentloaded' });
await waitForArgonPage(page);
const pre = page.locator('#post_content pre.hljs-codeblock, #post_content pre.wp-block-code.hljs-codeblock').first();
const lineCodes = pre.locator('.hljs-ln-code');
await expect(page.getByText('后端代码块运行时').first()).toBeVisible();
await expect(pre).toBeVisible();
await expect(pre.locator('table.hljs-ln')).toHaveCount(1);
await expect(pre.locator('table.hljs-ln > tbody > tr')).toHaveCount(2);
await expect(pre.locator(':scope > .hljs-control')).toHaveCount(1);
await expect(pre.locator('.hljs-control-btn')).toHaveCount(4);
await expect(lineCodes.nth(0)).toHaveText('const a = 1;');
await expect(lineCodes.nth(0)).toHaveAttribute('data-line-number', '1');
await expect(lineCodes.nth(1)).toHaveText('const b = a + 1;');
await expect(lineCodes.nth(1)).toHaveAttribute('data-line-number', '2');
await pre.locator('.hljs-control-toggle-break-line').click();
await expect(pre).toHaveClass(/hljs-break-line/);
await pre.locator('.hljs-control-toggle-linenumber').click();
await expect(pre).toHaveClass(/hljs-hide-linenumber/);
};
}
/**
* @returns Playwright test body that verifies code copy produces Argon's toast-style user feedback.
*/
function createPostCodeblockCopyFeedbackTest() {
/**
* @param fixtures Playwright page fixture used to grant clipboard permission and click the copied code control.
*/
return async function runPostCodeblockCopyFeedbackTest({ page }: PageFixture) {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
const postRoute = ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'post-61')!;
await gotoLocalRoute(page, postRoute);
const pre = page.locator('#post_content pre.hljs-codeblock, #post_content pre.wp-block-code.hljs-codeblock').first();
await pre.locator('.hljs-control-copy').click();
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
const normalizedClipboardText = clipboardText.replace(/\r\n/g, '\n');
expect(normalizedClipboardText.startsWith('Admin QQBot 账号页\n -> 扫码新增 / 更新登录')).toBe(true);
expect(normalizedClipboardText).not.toContain('1 Admin QQBot 账号页');
await expect(page.locator('.iziToast-wrapper.iziToast-wrapper-topRight')).toBeVisible();
await expect(page.locator('.iziToast.shadow')).toContainText('复制成功');
await expect(page.locator('.iziToast.shadow')).toContainText('代码已复制到剪贴板');
await expect(page.locator('.iziToast.shadow')).toHaveCSS('background-color', 'rgb(45, 206, 137)');
};
}
/**
* @param route Intercepted Blog article list request from the local Vue app.
*/
async function fulfillBackendBaseCodeblockArticleList(route: Route) {
await route.fulfill({
contentType: 'application/json',
json: {
code: 0,
data: {
list: [BACKEND_BASE_CODEBLOCK_ARTICLE],
total: 1,
},
msg: 'ok',
},
});
}
/**
* @param route Intercepted Blog article detail request from the local Vue app.
*/
async function fulfillBackendBaseCodeblockArticleDetail(route: Route) {
await route.fulfill({
contentType: 'application/json',
json: {
code: 0,
data: BACKEND_BASE_CODEBLOCK_ARTICLE,
msg: 'ok',
},
});
}
/**
* @returns Playwright test body that verifies inline WordPress links inherit Argon's live color and hover underline.
*/
function createPostInlineLinkSurfaceTest() {
/**
* @param fixtures Playwright page fixture used to inspect a post with raw WordPress anchor links.
*/
return async function runPostInlineLinkSurfaceTest({ page }: PageFixture) {
const postRoute = ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'post-9')!;
await gotoLocalRoute(page, postRoute);
const link = page.locator('#post_content p a[href]').first();
await expect(link).toHaveCSS('color', 'rgb(222, 204, 245)');
await expect(link).toHaveCSS('text-decoration-line', 'none');
await expect(link).toHaveCSS('border-bottom-width', '0px');
await expect(link).toHaveCSS('overflow-wrap', 'break-word');
await link.hover();
await expect(link).toHaveCSS('color', 'rgb(195, 161, 237)');
expect(
await link.evaluate((element) => getComputedStyle(element, '::before').transform),
).toBe('matrix(1, 0, 0, 1, 0, 0)');
};
}
/**
* @returns Playwright test body that checks Argon image wrappers open the Fancybox runtime shell.
*/
function createPostFancyboxRuntimeTest() {
/**
* @param fixtures Playwright page fixture used to click WordPress image wrappers inside rendered post content.
*/
return async function runPostFancyboxRuntimeTest({ page }: PageFixture) {
const postRoute = ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'post-20')!;
await gotoLocalRoute(page, postRoute);
const wrapper = page.locator('#post_content .fancybox-wrapper[href]').first();
await wrapper.click();
await expect(page.locator('body')).toHaveClass(/fancybox-active/);
await expect(page.locator('body')).toHaveClass(/compensate-for-scrollbar/);
await expect(page.locator('.fancybox-container.fancybox-is-open')).toBeVisible();
await expect(page.locator('.fancybox-toolbar')).toBeVisible();
await expect(page.locator('.fancybox-button--close')).toBeVisible();
await expect(page.locator('.fancybox-stage')).toBeVisible();
await page.locator('.fancybox-button--close').click();
await expect(page.locator('.fancybox-container')).toHaveCount(0);
await expect(page.locator('body')).not.toHaveClass(/fancybox-active/);
};
}
/**
* @returns Playwright test body that verifies WordPress `hljs-codeblock` HTML keeps Argon's nested layout.
*/
function createPostCodeblockSurfaceTest() {
/**
* @param fixtures Playwright page fixture used to inspect a post with rendered highlight.js line tables.
*/
return async function runPostCodeblockSurfaceTest({ page }: PageFixture) {
const postRoute = ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'post-61')!;
await gotoLocalRoute(page, postRoute);
const pre = page.locator('#post_content pre.hljs-codeblock, #post_content pre.wp-block-code.hljs-codeblock').first();
const code = pre.locator('code').first();
const table = pre.locator('table.hljs-ln').first();
const lineNumber = pre.locator('.hljs-ln-numbers').first();
const lineNumberInner = lineNumber.locator('.hljs-ln-n').first();
const lineCode = pre.locator('.hljs-ln-code').first();
const secondLineNumber = pre.locator('.hljs-ln-numbers').nth(1);
const secondLineNumberInner = secondLineNumber.locator('.hljs-ln-n').first();
const secondLineCode = pre.locator('.hljs-ln-code').nth(1);
const control = pre.locator('.hljs-control').first();
const lineNumberButton = pre.locator('.hljs-control-toggle-linenumber').first();
await expect(pre).toHaveCSS('position', 'relative');
await expect(pre).toHaveCSS('margin', '0px 0px 16px');
expect(await readPseudoStyle(pre, '::before', 'display')).toBe('block');
expect(await readPseudoStyle(pre, '::before', 'width')).toBe('54px');
expect(await readPseudoStyle(pre, '::before', 'height')).toBe('14px');
expect(await readPseudoStyle(pre, '::before', 'top')).toBe('22px');
expect(await readPseudoStyle(pre, '::before', 'left')).toBe('20px');
expect(await readPseudoStyle(pre, '::before', 'backgroundImage')).toContain('data:image/svg+xml');
await expect(code).toHaveCSS('display', 'block');
await expect(code).toHaveCSS('margin', '15px 0px');
await expect(code).toHaveCSS('padding', '55px 20px 22px');
await expect(code).toHaveCSS('border-radius', '8px');
await expect(code).toHaveCSS('box-shadow', 'rgba(0, 0, 0, 0.2) 0px 5px 20px 0px');
await expect(code).toHaveCSS('background-color', 'rgb(40, 44, 52)');
await expect(code).toHaveCSS('color', 'rgb(171, 178, 191)');
await expect(code).toHaveCSS('font-size', '16px');
await expect(code).toHaveCSS('line-height', '24px');
await expect(table).toHaveCSS('display', 'table');
await expect(table).toHaveCSS('margin', '0px');
await expect(table).toHaveCSS('white-space', 'pre');
await expect(lineNumber).toHaveCSS('display', 'block');
await expect(lineNumber).toHaveCSS('position', 'absolute');
await expect(lineNumber).toHaveCSS('padding', '0px 12px 0px 0px');
await expect(lineNumber).toHaveCSS('border-top-width', '0px');
await expect(lineNumber).toHaveAttribute('data-line-number', '1');
await expect(lineNumberInner).toHaveAttribute('data-line-number', '1');
expect(await readPseudoStyle(lineNumberInner, '::before', 'content')).toBe('"1"');
await expectRect(lineNumber, { height: 24 });
await expect(lineCode).toHaveCSS('padding-left', '30px');
await expect(lineCode).toHaveAttribute('data-line-number', '1');
await expect(lineCode).toHaveCSS('border-top-width', '0px');
await expect(secondLineNumber).toHaveAttribute('data-line-number', '2');
await expect(secondLineNumberInner).toHaveAttribute('data-line-number', '2');
expect(await readPseudoStyle(secondLineNumberInner, '::before', 'content')).toBe('"2"');
await expect(secondLineCode).toHaveAttribute('data-line-number', '2');
await expect(control).toHaveCSS('position', 'absolute');
await expect(control).toHaveCSS('top', '16px');
await expect(control).toHaveCSS('right', '20px');
await expect(control).toHaveCSS('display', 'block');
await expect(control).toHaveCSS('opacity', '0');
await expect(lineNumberButton).toHaveCSS('width', '12px');
await expect(lineNumberButton).toHaveCSS('margin-left', '15px');
await expect(lineNumberButton).toHaveCSS('opacity', '0.8');
expect(await readPseudoStyle(lineNumberButton, '::before', 'content')).toBe('"隐藏行号"');
expect(await readPseudoStyle(lineNumberButton, '::before', 'top')).toBe('22px');
expect(await readPseudoStyle(lineNumberButton, '::before', 'width')).toBe('92px');
await pre.hover();
await expect(control).toHaveCSS('opacity', '0.4');
await lineNumberButton.hover();
await expect(control).toHaveCSS('opacity', '1');
await expect(lineNumberButton).toHaveCSS('opacity', '0.5');
expect(await readPseudoStyle(lineNumberButton, '::before', 'opacity')).toBe('1');
expect(await readPseudoStyle(lineNumberButton, '::before', 'top')).toBe('25px');
};
}
/**
* @returns Playwright test body that keeps the top navbar container aligned with the live Argon Bootstrap layout.
*/
function createHeaderContainerSurfaceTest() {
/**
* @param fixtures Playwright page fixture used to inspect the desktop navbar.
*/
return async function runHeaderContainerSurfaceTest({ page }: PageFixture) {
await page.setViewportSize({ height: 900, width: 1440 });
await gotoLocalRoute(page, ARGON_ROUTE_CASES[0]);
const navbar = page.locator('#navbar-main');
const container = page.locator('.kt-blog__header-container');
await expect(navbar).toHaveCSS('padding', '16px');
await expect(navbar).toHaveCSS('height', '78px');
await expect(container).toHaveClass(/container/);
await expect(container).toHaveCSS('padding', '0px 15px');
await expect(container).toHaveCSS('width', '1200px');
await expectRect(container, { height: 46, width: 1200 });
};
}
/**
* @returns Playwright test body that checks leftbar menu items against the live Argon item surface.
*/
function createSidebarMenuSurfaceTest() {
/**
* @param fixtures Playwright page fixture used to inspect the home leftbar menu.
*/
return async function runSidebarMenuSurfaceTest({ page }: PageFixture) {
await gotoLocalRoute(page, ARGON_ROUTE_CASES[0]);
const menu = page.locator('.kt-blog__sidebar-menu');
const firstItem = menu.locator('> li').first();
const homeLink = firstItem.locator('> a');
const homeIcon = homeLink.locator('> i');
const adminLink = menu.getByRole('link', { name: '管理' });
await expect(menu).toHaveClass(/leftbar-menu/);
await expect(firstItem).toHaveClass(/leftbar-menu-item/);
await expect(firstItem).not.toHaveClass(/current/);
await expect(homeLink).not.toHaveClass(/router-link-active|router-link-exact-active/);
await expect(homeLink).toHaveCSS('display', 'block');
await expect(homeLink).toHaveCSS('padding', '0px 20px');
await expect(homeLink).toHaveCSS('height', '36px');
await expect(homeLink).toHaveCSS('line-height', '36px');
await expect(homeLink).toHaveCSS('font-size', '14px');
await expect(homeLink).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(homeLink).toHaveCSS('transition', 'background 0.2s ease-in-out');
await expect(homeIcon).toHaveCSS('display', 'inline-block');
await expect(homeIcon).toHaveCSS('width', '15px');
await expect(homeIcon).toHaveCSS('margin-right', '8px');
await expect(adminLink).toHaveAttribute('href', 'http://blog.kwitsukasa.top/wp-admin/');
};
}
/**
* @returns Playwright test body that verifies the home leftbar overview matches Argon's non-navigating site-state card.
*/
function createSidebarOverviewSurfaceTest() {
/**
* @param fixtures Playwright page fixture used to inspect the home sidebar overview card.
*/
return async function runSidebarOverviewSurfaceTest({ page }: PageFixture) {
await gotoLocalRoute(page, ARGON_ROUTE_CASES[0]);
const overviewInner = page.locator('#leftbar_part2_inner');
const hiddenTabs = page.locator('#leftbar_part2_inner > .nav-wrapper');
const overviewPane = page.locator('#leftbar_tab_overview');
const postCountLink = page.locator('.site-state-posts a');
const beforeUrl = page.url();
await expect(overviewInner).toHaveClass(/card-body/);
await expectRect(overviewInner, { height: 252, width: 250 });
await expect(overviewInner).toHaveCSS('padding', '10px');
await expect(overviewInner).toHaveCSS('overflow-y', 'auto');
await expect(hiddenTabs).toHaveCount(1);
await expect(hiddenTabs).toHaveCSS('display', 'none');
await expect(page.locator('#leftbar_part2_inner > div > .tab-content')).toHaveCount(1);
await expect(page.locator('#leftbar_part2_inner > .kt-blog__sidebar-overview-content')).toHaveCount(0);
await expect(overviewPane).toHaveClass(/tab-pane/);
await expect(overviewPane).toHaveClass(/fade/);
await expect(overviewPane).toHaveClass(/text-center/);
await expect(overviewPane).toHaveClass(/active/);
await expect(overviewPane).toHaveCSS('transition', 'opacity 0.15s linear');
await expect(overviewPane).toHaveCSS('text-align', 'center');
await expect(page.locator('#leftbar_overview_author_image')).toHaveClass(/rounded-circle/);
await expect(page.locator('#leftbar_overview_author_image')).toHaveClass(/shadow-sm/);
await expectRect(page.locator('#leftbar_overview_author_image'), { height: 100, width: 100 });
await expect(page.locator('#leftbar_overview_author_name')).toHaveCSS('margin', '15px 0px 8px');
await expect(page.locator('.site-state')).toHaveCSS('width', '210px');
await expect(postCountLink).not.toHaveAttribute('href');
await expect(postCountLink).toHaveCSS('cursor', 'default');
await postCountLink.click({ force: true });
await page.waitForTimeout(250);
expect(page.url()).toBe(beforeUrl);
};
}
/**
* @returns Playwright test body that checks the post leftbar overview/catalog tabs are real controls.
*/
function createPostSidebarTabTest() {
/**
* @param fixtures Playwright page fixture used to inspect post sidebar panes.
*/
return async function runPostSidebarTabTest({ page }: PageFixture) {
const postRoute = ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'post-50')!;
await gotoLocalRoute(page, postRoute);
const mainBoxBefore = await page.locator('.kt-blog__main').boundingBox();
const tabLabels = await page.locator('.kt-blog__sidebar-tabs [id^="leftbar_tab_"]').evaluateAll((tabs) =>
tabs.map((tab) => tab.textContent?.replace(/\s+/g, ' ').trim()),
);
expect(tabLabels).toEqual(['文章目录', '站点概览']);
await expect(page.locator('.kt-blog__sidebar-tabs')).toHaveCSS('padding', '5px 0px 16px');
await expect(page.locator('#leftbar_tab_catalog_btn')).toHaveCSS('font-size', '13px');
await expect(page.locator('#leftbar_tab_catalog_btn')).toHaveCSS('border-bottom-width', '1px');
await expect(page.locator('#leftbar_tab_catalog_btn')).toHaveAttribute('data-toggle', 'tab');
await expect(page.locator('#leftbar_tab_catalog_btn')).toHaveAttribute('no-pjax', '');
await expect(page.locator('#leftbar_tab_catalog')).toHaveCSS('transition', 'opacity 0.15s linear');
await expect(page.locator('#leftbar_tab_overview')).toHaveCSS('transition', 'opacity 0.15s linear');
await expect(page.locator('#leftbar_tab_overview')).toHaveCSS('opacity', '0');
await expect(page.locator('#leftbar_tab_catalog')).toBeVisible();
await expect(page.locator('#leftbar_tab_overview')).toBeHidden();
await expect(page.locator('#leftbar_tab_catalog_btn')).toHaveClass(/kt-blog__sidebar-tab--active/);
await expect(page.locator('#leftbar_catalog .index-link')).toHaveCount(37);
await expect(page.locator('#leftbar_catalog .index-link').nth(0)).toHaveText('1. 方案摘要');
await expect(page.locator('#leftbar_catalog .index-link').nth(1)).toHaveText('2. 建设目标');
await expect(page.locator('#leftbar_catalog .index-link').nth(2)).toHaveText('2.1 业务目标');
await expect(page.locator('#leftbar_catalog .index-link').nth(3)).toHaveText('2.2 技术目标');
await expect(page.locator('#leftbar_catalog .index-link').nth(4)).toHaveText('3. 总体架构');
await expect(page.locator('#leftbar_catalog .index-link').first()).toHaveAttribute('href', '#header-id-1');
await expect(page.locator('#leftbar_catalog .index-subItem-box').first()).toHaveCSS('display', 'none');
await expect(page.locator('#leftbar_catalog .index-link').first()).toHaveCSS('font-size', '15px');
await expect(page.locator('#leftbar_catalog .index-link').first()).toHaveCSS('padding', '4px 8px');
await expect(page.locator('#leftbar_catalog .index-link').first()).toHaveCSS('border-left-width', '3px');
await expect(page.locator('#post_content')).toHaveCSS('line-height', '28.8px');
await expect(page.locator('#post_content p').first()).toHaveCSS('line-height', '28.8px');
await expect(page.locator('#header-id-2')).toHaveCSS('font-size', '26px');
await expect(page.locator('#header-id-2')).toHaveCSS('line-height', '39px');
await expect(page.locator('#header-id-2')).toHaveCSS('color', 'rgb(238, 238, 238)');
await expect(page.locator('#header-id-2')).toHaveCSS('margin-top', '18px');
await expect(page.locator('#header-id-2')).toHaveCSS('margin-bottom', '15px');
await expect(page.locator('#post_content h3').first()).toHaveCSS('line-height', '33px');
await expect(page.locator('#post_content h3').first()).toHaveCSS('color', 'rgb(238, 238, 238)');
await expect(page.locator('#post_content strong').first()).toHaveCSS('font-weight', '600');
await expect(page.locator('#post_content ul').first()).toHaveCSS('margin', '0px 0px 16px');
await expect(page.locator('#post_content ul').first()).toHaveCSS('padding-left', '40px');
await expect(page.locator('#post_content .wp-block-preformatted').first()).toHaveCSS('background-color', 'rgb(60, 54, 67)');
await expect(page.locator('#post_content .wp-block-preformatted').first()).toHaveCSS('border-radius', '3px');
await expect(page.locator('#post_content .wp-block-preformatted').first()).toHaveCSS('font-size', '14px');
await expect(page.locator('#post_content .wp-block-preformatted').first()).toHaveCSS('line-height', '19.25px');
await expect(page.locator('#post_content .wp-block-preformatted').first()).toHaveCSS('padding', '14px');
await expect(page.locator('#post_content .wp-block-preformatted').first()).toHaveCSS('border-top-width', '1px');
await expect(page.locator('#post_content p code').first()).toHaveCSS('padding', '2px 5px');
await expect(page.locator('#post_content p code').first()).toHaveCSS('border-top-width', '1px');
await expect(page.locator('#post_content p code').first()).toHaveCSS('background-color', 'rgb(60, 54, 67)');
await expect(page.locator('#post_content p code').first()).toHaveCSS('border-radius', '3px');
await expect(page.locator('#post_content p code').first()).toHaveCSS('white-space', 'normal');
await expect(page.locator('#post_content figure.wp-block-table').first()).toHaveCSS('margin', '48px 0px');
await expect(page.locator('#post_content figure.wp-block-table').first()).toHaveCSS('overflow-x', 'auto');
await expect(page.locator('#post_content figure.wp-block-table table').first()).toHaveCSS('display', 'table');
await expect(page.locator('#post_content figure.wp-block-table table').first()).toHaveCSS('table-layout', 'fixed');
await expect(page.locator('#post_content figure.wp-block-table table').first()).toHaveCSS('white-space', 'normal');
await expect(page.locator('#post_content figure.wp-block-table table').first()).toHaveCSS('word-break', 'break-word');
await expect(page.locator('#post_content figure.wp-block-table th').first()).toHaveCSS('padding', '8px');
await expect(page.locator('#post_content figure.wp-block-table th').first()).toHaveCSS('text-align', 'left');
await expect(page.locator('#post_content figure.wp-block-table th').first()).toHaveCSS('vertical-align', 'top');
await expect(page.locator('#post_content figure.wp-block-table td').first()).toHaveCSS('padding', '8px');
await expect(page.locator('#post_content figure.wp-block-table td').first()).toHaveCSS('vertical-align', 'top');
const postContentStats = await page.locator('#post_content').evaluate((element) => {
const secondHeading = document.querySelector('#header-id-2');
return {
header2OffsetTop: Math.round((secondHeading?.getBoundingClientRect().top ?? 0) + window.scrollY),
textLength: element.textContent?.trim().length ?? 0,
};
});
expect(postContentStats.textLength).toBeGreaterThan(9000);
expect(postContentStats.header2OffsetTop).toBeGreaterThan(900);
const secondHeadingActiveBoundary = await page.locator('#header-id-2').evaluate((element) => {
const ARGON_CATALOG_OFFSET = 80;
return Math.ceil(element.getBoundingClientRect().top + window.scrollY - ARGON_CATALOG_OFFSET);
});
await page.evaluate((scrollTop) => window.scrollTo(0, scrollTop - 1), secondHeadingActiveBoundary);
await expect
.poll(
() => page.locator('#leftbar_catalog .index-item.current > .index-link').evaluate((element) => element.textContent?.trim()),
{ message: 'catalog keeps the previous heading before the Argon activation boundary' },
)
.toBe('1. 方案摘要');
await page.evaluate((scrollTop) => window.scrollTo(0, scrollTop), secondHeadingActiveBoundary);
await expect
.poll(
() => page.locator('#leftbar_catalog .index-item.current > .index-link').evaluate((element) => element.textContent?.trim()),
{ message: 'catalog switches at the Argon activation boundary' },
)
.toBe('2. 建设目标');
await page.evaluate(() => window.scrollTo(0, 0));
await expect
.poll(
() => page.locator('#leftbar_catalog .index-item.current > .index-link').evaluate((element) => element.textContent?.trim()),
{ message: 'catalog returns to the first heading after scrolling back to top' },
)
.toBe('1. 方案摘要');
const beforeClickHash = await page.evaluate(() => window.location.hash);
const beforeClickScrollY = await page.evaluate(() => window.scrollY);
await page.locator('#leftbar_catalog .index-link').nth(1).click();
await expect(page.locator('#leftbar_catalog .index-item.current > .index-link')).toHaveText('2. 建设目标');
await expect(page.locator('#leftbar_catalog .index-subItem-box').first()).toHaveCSS('display', 'block');
await page.waitForTimeout(100);
const slideHeightAt100ms = await page
.locator('#leftbar_catalog .index-subItem-box')
.first()
.evaluate((element) => Math.round(element.getBoundingClientRect().height));
expect(slideHeightAt100ms).toBeGreaterThan(15);
expect(slideHeightAt100ms).toBeLessThan(61);
await expect
.poll(
() =>
page.locator('#leftbar_catalog .index-item.current > .index-link').evaluate((element) => {
const probe = document.createElement('span');
probe.style.color = document.documentElement.classList.contains('darkmode')
? 'var(--themecolor-light)'
: 'var(--themecolor)';
document.querySelector('.kt-blog')?.append(probe);
const expectedColor = getComputedStyle(probe).color;
probe.remove();
return `${getComputedStyle(element).color}|${expectedColor}`;
}),
{ message: 'catalog current color reaches Argon theme color after transition' },
)
.toBe(await page.locator('#leftbar_catalog .index-item.current > .index-link').evaluate(() => {
const probe = document.createElement('span');
probe.style.color = document.documentElement.classList.contains('darkmode')
? 'var(--themecolor-light)'
: 'var(--themecolor)';
document.querySelector('.kt-blog')?.append(probe);
const expectedColor = getComputedStyle(probe).color;
probe.remove();
return `${expectedColor}|${expectedColor}`;
}));
expect(await page.evaluate(() => window.location.hash)).toBe(beforeClickHash);
await expect
.poll(() => page.evaluate(() => window.scrollY), { message: 'catalog click scrolls the document' })
.toBeGreaterThan(beforeClickScrollY + 850);
await expect
.poll(() => page.locator('#header-id-2').evaluate((element) => Math.round(element.getBoundingClientRect().top)))
.toBeLessThan(120);
await page.waitForTimeout(450);
await page.evaluate(() => window.scrollTo(0, Math.round(document.documentElement.scrollHeight * 0.75)));
await expect
.poll(
() => page.locator('#leftbar_catalog .index-item.current > .index-link').evaluate((element) => element.textContent?.trim()),
{ message: 'catalog current follows document scroll' },
)
.toBe('13. 安全边界');
await expect(page.locator('#leftbar_catalog .index-subItem-box').first()).toHaveCSS('display', 'none');
await page.locator('#leftbar_tab_overview_btn').click();
await expect(page.locator('#leftbar_tab_catalog')).toHaveCSS('opacity', '0');
await expect(page.locator('#leftbar_tab_overview')).toHaveCSS('opacity', '1');
await expect(page.locator('#leftbar_tab_overview')).toBeVisible();
await expect(page.locator('#leftbar_tab_catalog')).toBeHidden();
await expect(page.locator('#leftbar_tab_overview_btn')).toHaveClass(/kt-blog__sidebar-tab--active/);
await expect(page.locator('.kt-blog__site-stats')).toHaveCSS('display', 'block');
await expect(page.locator('.kt-blog__site-stats')).toHaveCSS('white-space', 'nowrap');
await expect(page.locator('.kt-blog__site-stats-item').nth(1)).toHaveCSS('border-left-width', '1px');
await expect(page.locator('.kt-blog__site-stats-count').first()).toHaveCSS('font-size', '16px');
await expect(page.locator('.kt-blog__site-stats-name').first()).toHaveCSS('font-size', '13px');
const mainBoxAfter = await page.locator('.kt-blog__main').boundingBox();
expect(Math.round(mainBoxAfter?.width ?? 0)).toBe(Math.round(mainBoxBefore?.width ?? 0));
};
}
/**
* @returns Playwright test body that keeps the sidebar tab fade on Argon's active/show class-frame timing.
*/
function createPostSidebarClassFrameSourceTest() {
return async function runPostSidebarClassFrameSourceTest() {
const source = await readFile(new URL('../../src/components/blog/BlogSidebar.tsx', import.meta.url), 'utf8');
expect(source).toContain('visiblePaneTab');
expect(source).toContain('shownPaneTab');
expect(source).toContain('tabFadeFrame');
expect(source).toContain('requestBlogFrame');
expect(source).toContain('@/factories/blogAnimationFactory');
expect(source).not.toContain('kt-blog__sidebar-tab-pane');
expect(source).not.toMatch(/\bTransition\b/);
expect(source).not.toContain('vShow');
};
}
/**
* @returns Playwright test body that prevents replaceable Argon behavior from regressing to native id queries.
*/
function createReplaceableDomRefSourceTest() {
return async function runReplaceableDomRefSourceTest() {
const sidebarSource = await readFile(new URL('../../src/components/blog/BlogSidebar.tsx', import.meta.url), 'utf8');
const postPageSource = await readFile(new URL('../../src/views/blog/PostPage.tsx', import.meta.url), 'utf8');
const commentsStyle = await readFile(new URL('../../src/styles/searchComments.scss', import.meta.url), 'utf8');
expect(sidebarSource).toContain('ref={props.part1Ref}');
expect(sidebarSource).toContain('ref={props.part2Ref}');
expect(sidebarSource).toContain('ref={registerCatalogRef}');
expect(postPageSource).toContain('ref={registerPostContentRef}');
expect(`${sidebarSource}\n${postPageSource}`).not.toMatch(/document\.(?:getElementById|querySelector(?:All)?)\([^)]*['"]#?(?:leftbar|post_content|post_comment)/);
expect(commentsStyle).not.toContain('#post_comment_send');
expect(commentsStyle).not.toContain('#comment_emotion_btn');
};
}
/**
* @returns Playwright test body that verifies Argon's tab-pane fade has a real intermediate opacity frame.
*/
function createPostSidebarTabFadeTest() {
/**
* @param fixtures Playwright page fixture used to inspect post sidebar tab animation timing.
*/
return async function runPostSidebarTabFadeTest({ page }: PageFixture) {
const postRoute = ARGON_ROUTE_CASES.find((routeCase) => routeCase.routeKey === 'post-50')!;
await gotoLocalRoute(page, postRoute);
const overviewPane = page.locator('#leftbar_tab_overview');
await expect(overviewPane).toHaveCSS('opacity', '0');
await page.locator('#leftbar_tab_overview_btn').click();
await page.waitForTimeout(70);
const switchingOpacity = await overviewPane.evaluate((element) => Number(getComputedStyle(element).opacity));
expect(switchingOpacity).toBeGreaterThan(0);
expect(switchingOpacity).toBeLessThan(1);
await expect(overviewPane).toHaveCSS('opacity', '1');
await expect(overviewPane).toBeVisible();
};
}
/**
* @param page Browser page containing a FontAwesome `` element.
* @param selector CSS selector for the icon element whose pseudo content must render.
*/
async function expectVisibleFontAwesomeIcon(page: Page, selector: string) {
await expect
.poll(
() =>
page.locator(selector).evaluate((element) => {
const rect = element.getBoundingClientRect();
const beforeContent = getComputedStyle(element, '::before').content;
return rect.height > 0 && beforeContent !== 'none' && beforeContent !== '""';
}),
{ message: `${selector} renders FontAwesome pseudo content` },
)
.toBe(true);
}
test.describe('Argon local interaction parity', registerLocalInteractionSuite);