fix(web): restore the plugin header subtitle row to its flex layout

The responsive change had moved `.pageIntro` off flex, which put the information
icon 1.06px off the subtitle's vertical centre and failed the existing geometry
assertion in `plugin-manager.e2e.ts`. Restore the flex row and the button's
`flex: none`, which keeps the 4px gap and the centring. The header still wraps
its action group onto another line, which is what this change is for.

Pin the wrap threshold in both header scenarios. Each records whether the action
group sits beside or below the heading, the plugin scenario asserts the wide and
narrow sides explicitly, and the plugin scenario launches its own scaffold so an
install task left by an earlier test cannot move the threshold between runs.
This commit is contained in:
Yif committed 2026-10-12 23:15:20 +08:00
1 parent 5a52b980e5
commit 9e62c1c229
5 files changed
+133 -104

No files matched your search

@@ -1,18 +1,18 @@
# Plugin header responsive layout
en 1680px: actions single-line and contained; heading separate; information follows subtitle; focus retained
en 768px: actions single-line and contained; heading separate; information follows subtitle; focus retained
en 520px: actions single-line and contained; heading separate; information follows subtitle; focus retained
en 440px: actions single-line and contained; heading separate; information follows subtitle; focus retained
en 390px: actions single-line and contained; heading separate; information follows subtitle; focus retained
en 430px: actions single-line and contained; heading separate; information follows subtitle; focus retained
en 1024px: actions single-line and contained; heading separate; information follows subtitle; focus retained
zh 1680px: actions single-line and contained; heading separate; information follows subtitle; focus retained
zh 768px: actions single-line and contained; heading separate; information follows subtitle; focus retained
zh 520px: actions single-line and contained; heading separate; information follows subtitle; focus retained
zh 440px: actions single-line and contained; heading separate; information follows subtitle; focus retained
zh 390px: actions single-line and contained; heading separate; information follows subtitle; focus retained
zh 430px: actions single-line and contained; heading separate; information follows subtitle; focus retained
zh 1024px: actions single-line and contained; heading separate; information follows subtitle; focus retained
en 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
en 768px: actions below the heading, single-line; contained; information follows subtitle; focus retained
en 520px: actions below the heading, single-line; contained; information follows subtitle; focus retained
en 440px: actions below the heading, single-line; contained; information follows subtitle; focus retained
en 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained
en 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained
en 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
zh 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
zh 768px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
zh 520px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
zh 440px: actions below the heading, single-line; contained; information follows subtitle; focus retained
zh 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained
zh 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained
zh 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
Add menu dismisses on Escape and returns focus in both locales.
@@ -1,17 +1,17 @@
# Automation tasks header
en 1680px: title and New single-line; no overlap or overflow; New retains focus
en 800px: title and New single-line; no overlap or overflow; New retains focus
en 520px: title and New single-line; no overlap or overflow; New retains focus
en 440px: title and New single-line; no overlap or overflow; New retains focus
en 390px: title and New single-line; no overlap or overflow; New retains focus
en 430px: title and New single-line; no overlap or overflow; New retains focus
en 1024px: title and New single-line; no overlap or overflow; New retains focus
en 1680px: title and New single-line, New beside the title; contained; New retains focus
en 800px: title and New single-line, New beside the title; contained; New retains focus
en 520px: title and New single-line, New beside the title; contained; New retains focus
en 440px: title and New single-line, New beside the title; contained; New retains focus
en 390px: title and New single-line, New beside the title; contained; New retains focus
en 430px: title and New single-line, New beside the title; contained; New retains focus
en 1024px: title and New single-line, New beside the title; contained; New retains focus
en 509px with sidebar expanded: title single-line; New moves below the title
zh 1680px: title and New single-line; no overlap or overflow; New retains focus
zh 800px: title and New single-line; no overlap or overflow; New retains focus
zh 520px: title and New single-line; no overlap or overflow; New retains focus
zh 440px: title and New single-line; no overlap or overflow; New retains focus
zh 390px: title and New single-line; no overlap or overflow; New retains focus
zh 430px: title and New single-line; no overlap or overflow; New retains focus
zh 1024px: title and New single-line; no overlap or overflow; New retains focus
zh 1680px: title and New single-line, New beside the title; contained; New retains focus
zh 800px: title and New single-line, New beside the title; contained; New retains focus
zh 520px: title and New single-line, New beside the title; contained; New retains focus
zh 440px: title and New single-line, New beside the title; contained; New retains focus
zh 390px: title and New single-line, New beside the title; contained; New retains focus
zh 430px: title and New single-line, New beside the title; contained; New retains focus
zh 1024px: title and New single-line, New beside the title; contained; New retains focus
+87 -69
View File
@@ -98,80 +98,98 @@ describe('web e2e: plugin manager', () => {
it('keeps plugin header actions on one line as the available width changes', async () => {
const facts: string[] = []
for (const language of ['en', 'zh'] as const) {
const context = await browser.newContext({
viewport: { width: 1680, height: 1000 }, locale: language === 'en' ? 'en-US' : ZH_BROWSER_LOCALE,
reducedMotion: 'reduce',
})
try {
const probe = await context.newPage()
const consoleWatch = watchConsole(probe)
await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const title = language === 'en' ? 'Plugins' : '插件'
await probe.getByRole('button', { name: title, exact: true }).click()
const panel = probe.locator('[data-plugin-panel]')
const header = panel.locator(':scope > header')
await header.getByRole('heading', { name: title, exact: true }).waitFor()
await probe.evaluate(() => document.fonts.ready)
const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true })
await expect.poll(() => add.isEnabled()).toBe(true)
await add.focus()
for (const width of [1680, 768, 520, 440, 390, 430, 1024]) {
await probe.setViewportSize({ width, height: 1000 })
const measure = () => header.evaluate((element) => {
const heading = element.firstElementChild!
const actions = element.lastElementChild!
const headingRect = heading.getBoundingClientRect()
const actionRect = actions.getBoundingClientRect()
const headerRect = element.getBoundingClientRect()
const subtitle = heading.querySelector('h1 + div > span')!
const range = document.createRange()
range.selectNodeContents(subtitle)
const textRects = Array.from(range.getClientRects())
const lastText = textRects.at(-1)!
const info = heading.querySelector('button')!.getBoundingClientRect()
const buttons = Array.from(actions.querySelectorAll('button'))
const singleLine = buttons.every((button) => {
const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT)
const tops: number[] = []
while (walker.nextNode()) {
if (!walker.currentNode.textContent?.trim()) continue
range.selectNodeContents(walker.currentNode)
for (const rect of range.getClientRects()) tops.push(rect.top)
// A dedicated scaffold keeps the measured header deterministic: an install
// task left by an earlier test swaps the toolbar's add control for the
// view-installation button, which moves the wrap threshold.
const headerScaffold = await launchWebScaffold()
try {
// The widths where the header must have wrapped or not are pinned; the
// remaining widths assert only that the action group is placed on one side
// of the heading, and the golden records which.
const PINNED_PLACEMENT: Record<number, 'beside' | 'below'> = { 1680: 'beside', 1024: 'beside', 390: 'below', 430: 'below' }
for (const language of ['en', 'zh'] as const) {
const context = await browser.newContext({
viewport: { width: 1680, height: 1000 }, locale: language === 'en' ? 'en-US' : ZH_BROWSER_LOCALE,
reducedMotion: 'reduce',
})
try {
const probe = await context.newPage()
const consoleWatch = watchConsole(probe)
await probe.goto(headerScaffold.authenticatedUrl, { waitUntil: 'load' })
const title = language === 'en' ? 'Plugins' : '插件'
await probe.getByRole('button', { name: title, exact: true }).click()
const panel = probe.locator('[data-plugin-panel]')
const header = panel.locator(':scope > header')
await header.getByRole('heading', { name: title, exact: true }).waitFor()
await probe.evaluate(() => document.fonts.ready)
const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true })
await expect.poll(() => add.isEnabled()).toBe(true)
await add.focus()
for (const width of [1680, 768, 520, 440, 390, 430, 1024]) {
await probe.setViewportSize({ width, height: 1000 })
const measure = () => header.evaluate((element) => {
const heading = element.firstElementChild!
const actions = element.lastElementChild!
const headingRect = heading.getBoundingClientRect()
const actionRect = actions.getBoundingClientRect()
const headerRect = element.getBoundingClientRect()
const subtitle = heading.querySelector('h1 + div > span')!
const range = document.createRange()
range.selectNodeContents(subtitle)
const textRects = Array.from(range.getClientRects())
const lastText = textRects.at(-1)!
const info = heading.querySelector('button')!.getBoundingClientRect()
const buttons = Array.from(actions.querySelectorAll('button'))
const singleLine = buttons.every((button) => {
const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT)
const tops: number[] = []
while (walker.nextNode()) {
if (!walker.currentNode.textContent?.trim()) continue
range.selectNodeContents(walker.currentNode)
for (const rect of range.getClientRects()) tops.push(rect.top)
}
return tops.length === 0 || Math.max(...tops) - Math.min(...tops) < 1
})
return {
singleLine,
placement: actionRect.left >= headingRect.right - 1
? 'beside' as const
: actionRect.top >= headingRect.bottom - 1 ? 'below' as const : 'overlapping' as const,
contained: actionRect.left >= headerRect.left - 1 && actionRect.right <= headerRect.right + 1,
infoFollowsText: info.left >= lastText.right && info.left - lastText.right <= 5
&& info.top < lastText.bottom && info.bottom > lastText.top,
focused: buttons.includes(document.activeElement as HTMLButtonElement),
}
return tops.length === 0 || Math.max(...tops) - Math.min(...tops) < 1
})
return {
singleLine,
separated: actionRect.top >= headingRect.bottom - 1 || actionRect.left >= headingRect.right - 1,
contained: actionRect.left >= headerRect.left - 1 && actionRect.right <= headerRect.right + 1,
infoFollowsText: info.left >= lastText.right && info.left - lastText.right <= 5
&& info.top < lastText.bottom && info.bottom > lastText.top,
focused: buttons.includes(document.activeElement as HTMLButtonElement),
}
})
await expect.poll(measure).toEqual({
singleLine: true, separated: true, contained: true, infoFollowsText: true, focused: true,
})
facts.push(`${language} ${width}px: actions single-line and contained; heading separate; information follows subtitle; focus retained`)
const pinned = PINNED_PLACEMENT[width]
await expect.poll(measure).toMatchObject({
singleLine: true, contained: true, infoFollowsText: true, focused: true,
})
const placed = (await measure()).placement
if (pinned === undefined) expect(['beside', 'below']).toContain(placed)
else expect(placed).toBe(pinned)
facts.push(`${language} ${width}px: actions ${placed} the heading, single-line; contained; information follows subtitle; focus retained`)
}
await probe.setViewportSize({ width: 390, height: 1000 })
const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' })
await menuTrigger.click()
await probe.getByRole('menu').waitFor()
await probe.keyboard.press('Escape')
await expect.poll(() => probe.getByRole('menu').count()).toBe(0)
expect(await menuTrigger.evaluate(element => element === document.activeElement)).toBe(true)
expect(consoleWatch.pageErrors).toEqual([])
} finally {
await context.close()
}
await probe.setViewportSize({ width: 390, height: 1000 })
const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' })
await menuTrigger.click()
await probe.getByRole('menu').waitFor()
await probe.keyboard.press('Escape')
await expect.poll(() => probe.getByRole('menu').count()).toBe(0)
expect(await menuTrigger.evaluate(element => element === document.activeElement)).toBe(true)
expect(consoleWatch.pageErrors).toEqual([])
} finally {
await context.close()
}
await compareOrRefreshGolden(
join(SNAPSHOT_DIR, 'header-responsive.expected.md'),
`# Plugin header responsive layout\n\n${facts.join('\n')}\n\nAdd menu dismisses on Escape and returns focus in both locales.`,
MODE,
)
} finally {
await headerScaffold.close()
}
await compareOrRefreshGolden(
join(SNAPSHOT_DIR, 'header-responsive.expected.md'),
`# Plugin header responsive layout\n\n${facts.join('\n')}\n\nAdd menu dismisses on Escape and returns focus in both locales.`,
MODE,
)
})
it('aligns the first-read skeleton with the loaded plugin cards', async () => {
+15 -5
View File
@@ -31,6 +31,8 @@ it('keeps the automation title intact before moving New to another line', async
await create.focus()
for (const width of [1680, 800, 520, 440, 390, 430, 1024]) {
await page.setViewportSize({ width, height: 1000 })
// The title keeps its natural width at every viewport width, so New
// stays beside it here; the squeeze case below is what moves it down.
await expect.poll(() => header.evaluate((element) => {
const title = element.querySelector('h1')!
const button = element.querySelector('button')!
@@ -43,29 +45,37 @@ it('keeps the automation title intact before moving New to another line', async
return {
titleSingleLine: titleLines.length === 1,
buttonSingleLine: button.scrollHeight <= button.clientHeight,
separated: buttonRect.left >= titleRect.right - 1 || buttonRect.top >= titleRect.bottom - 1,
placement: buttonRect.left >= titleRect.right - 1
? 'beside' as const
: buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const,
contained: titleRect.left >= headerRect.left - 1 && titleRect.right <= headerRect.right + 1
&& buttonRect.left >= headerRect.left - 1 && buttonRect.right <= headerRect.right + 1,
focused: document.activeElement === button,
}
})).toEqual({
titleSingleLine: true, buttonSingleLine: true, separated: true, contained: true, focused: true,
titleSingleLine: true, buttonSingleLine: true, placement: 'beside', contained: true, focused: true,
})
facts.push(`${language} ${width}px: title and New single-line; no overlap or overflow; New retains focus`)
facts.push(`${language} ${width}px: title and New single-line, New beside the title; contained; New retains focus`)
}
if (language === 'en') {
// 509px collapses the left sidebar to its rail; expanding it leaves the
// page column narrow enough (measured 172px) to force the wrap.
await page.setViewportSize({ width: 509, height: 1000 })
await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
await expect.poll(() => header.evaluate((element) => {
const title = element.querySelector('h1')!
const button = element.querySelector('button')!
const titleRect = title.getBoundingClientRect()
const buttonRect = button.getBoundingClientRect()
const range = document.createRange()
range.selectNodeContents(title)
return {
titleSingleLine: range.getClientRects().length === 1,
actionBelow: button.getBoundingClientRect().top >= title.getBoundingClientRect().bottom,
placement: buttonRect.left >= titleRect.right - 1
? 'beside' as const
: buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const,
}
})).toEqual({ titleSingleLine: true, actionBelow: true })
})).toEqual({ titleSingleLine: true, placement: 'below' })
facts.push('en 509px with sidebar expanded: title single-line; New moves below the title')
}
expect(consoleWatch.pageErrors).toEqual([])
@@ -53,6 +53,9 @@
}
.pageIntro {
display: flex;
align-items: center;
gap: 4px;
margin: 4px 0 0;
font-size: 13px;
line-height: 20px;
@@ -63,9 +66,7 @@
flex: none;
width: 20px;
height: 20px;
margin-inline-start: 4px;
padding: 0;
vertical-align: middle;
color: var(--dsw-alias-label-caption);
}