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:
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
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user