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://localhost:5999/#/auth/login?sso=1&redirect=%2Fblog%2Farticle', ); }; } /** * @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);