|
|
@@ -28,6 +28,18 @@ async function command(page: Page, text: string): Promise<void> {
|
|
|
await page.keyboard.press('Enter')
|
|
|
}
|
|
|
|
|
|
+async function selectTerminalTheme(page: Page, name: string): Promise<void> {
|
|
|
+ await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
|
|
+ const dialog = page.getByRole('dialog', { name: 'Settings' })
|
|
|
+ const [response] = await Promise.all([
|
|
|
+ page.waitForResponse(candidate => new URL(candidate.url()).pathname === '/api/settings/mutate' && candidate.request().method() === 'POST'),
|
|
|
+ dialog.getByRole('button', { name, exact: true }).click(),
|
|
|
+ ])
|
|
|
+ expect(response.ok()).toBe(true)
|
|
|
+ await page.keyboard.press('Escape')
|
|
|
+ await dialog.waitFor({ state: 'hidden' })
|
|
|
+}
|
|
|
+
|
|
|
describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
|
|
|
let scaffold: WebScaffold
|
|
|
let browser: Browser
|
|
|
@@ -80,6 +92,92 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
+ it('preserves program palettes and keeps ANSI text and cursors readable across DSH themes', async () => {
|
|
|
+ onTestFailed(() => saveFailureShot(page, 'terminal-colors'))
|
|
|
+ await page.emulateMedia({ colorScheme: 'light' })
|
|
|
+ await openTerminal(page)
|
|
|
+ const terminal = page.locator('[data-sidebar-terminal]')
|
|
|
+ const screen = page.locator('.xterm-rows:visible')
|
|
|
+ await command(page, "PS1=''; printf '\\033c\\033[97mBRIGHT_WHITE\\033[0m\\n'")
|
|
|
+ const colorOf = async (text: string) => {
|
|
|
+ const cell = screen.getByText(text, { exact: true })
|
|
|
+ await cell.waitFor()
|
|
|
+ return cell.evaluate(element => ({
|
|
|
+ foreground: getComputedStyle(element).color, background: getComputedStyle(element).backgroundColor,
|
|
|
+ }))
|
|
|
+ }
|
|
|
+ const lightText = await colorOf('BRIGHT_WHITE')
|
|
|
+ // Compare the rendered glyph with its real surface; the raw ANSI palette remains untouched.
|
|
|
+ expect(contrastRatio(lightText.foreground, 'rgb(255, 255, 255)')).toBeGreaterThanOrEqual(4.5)
|
|
|
+ await command(page, "printf '\\033]4;1;#009900;255;#990099\\007\\033[31mANSI_CUSTOM\\033[38;5;255mEXTENDED_CUSTOM\\033[0m\\n'")
|
|
|
+ const customLight = { ansi: await colorOf('ANSI_CUSTOM'), extended: await colorOf('EXTENDED_CUSTOM') }
|
|
|
+ await selectTerminalTheme(page, 'Dark')
|
|
|
+ await expect.poll(() => screen.evaluate(element => getComputedStyle(element).color)).toBe('rgb(249, 250, 251)')
|
|
|
+ await selectTerminalTheme(page, 'Light')
|
|
|
+ await expect.poll(() => screen.evaluate(element => getComputedStyle(element).color)).toBe('rgb(15, 17, 21)')
|
|
|
+ expect({ ansi: await colorOf('ANSI_CUSTOM'), extended: await colorOf('EXTENDED_CUSTOM') }).toEqual(customLight)
|
|
|
+ await command(page, "printf '\\033]104;1;255\\007'")
|
|
|
+ await expect.poll(async () => (await colorOf('ANSI_CUSTOM')).foreground).not.toBe(customLight.ansi.foreground)
|
|
|
+ await expect.poll(async () => (await colorOf('EXTENDED_CUSTOM')).foreground).not.toBe(customLight.extended.foreground)
|
|
|
+
|
|
|
+ await command(page, "printf '\\033]10;#112233;#ddeeff;#990099\\007'")
|
|
|
+ const defaults = () => terminal.evaluate(root => ({
|
|
|
+ foreground: getComputedStyle(root.querySelector('.xterm-rows')!).color,
|
|
|
+ background: getComputedStyle(root.querySelector('.xterm-scrollable-element')!).backgroundColor,
|
|
|
+ }))
|
|
|
+ const applicationDefaults = { foreground: 'rgb(17, 34, 51)', background: 'rgb(221, 238, 255)' }
|
|
|
+ await expect.poll(defaults).toEqual(applicationDefaults)
|
|
|
+ await selectTerminalTheme(page, 'Dark')
|
|
|
+ await expect.poll(() => terminal.evaluate(root => getComputedStyle(root.querySelector('.xterm')!.parentElement!).backgroundColor))
|
|
|
+ .toBe('rgb(21, 21, 23)')
|
|
|
+ expect(await defaults()).toEqual(applicationDefaults)
|
|
|
+ await command(page, "printf '\\033]110\\007\\033]111\\007\\033]112\\007'")
|
|
|
+ await expect.poll(defaults).toEqual({ foreground: 'rgb(249, 250, 251)', background: 'rgb(21, 21, 23)' })
|
|
|
+ await selectTerminalTheme(page, 'Light')
|
|
|
+ await expect.poll(defaults).toEqual({ foreground: 'rgb(15, 17, 21)', background: 'rgb(255, 255, 255)' })
|
|
|
+
|
|
|
+ const cursorColors = () => screen.locator('.xterm-cursor').evaluate((cursor) => {
|
|
|
+ const style = getComputedStyle(cursor)
|
|
|
+ return {
|
|
|
+ background: style.backgroundColor, foreground: style.color,
|
|
|
+ shadow: style.boxShadow, border: style.borderBottomColor, outline: style.outlineColor,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ const paintCursor = async (sgr: string, shape = 2) => {
|
|
|
+ await command(page, `printf '\\033[0m\\033[2J\\033[H\\033[${sgr}mCURSOR\\033[1G\\033[${shape} q'`)
|
|
|
+ await expect.poll(() => screen.locator('.xterm-cursor').innerText()).toBe('C')
|
|
|
+ }
|
|
|
+ // ron uses foreground 51 and background 16; no installed Vim is required by CI.
|
|
|
+ await paintCursor('38;5;51;48;5;16')
|
|
|
+ await expect.poll(async () => (await cursorColors()).background).toBe('rgb(255, 255, 255)')
|
|
|
+ const ronLight = await cursorColors()
|
|
|
+ expect(ronLight.foreground).toBe('rgb(0, 0, 0)')
|
|
|
+ await terminal.screenshot({ path: `${shots}/ron-light.png`, animations: 'disabled' })
|
|
|
+ await selectTerminalTheme(page, 'Dark')
|
|
|
+ await page.locator('.xterm-helper-textarea:visible').click()
|
|
|
+ await expect.poll(async () => (await cursorColors()).background).toBe('rgb(249, 250, 251)')
|
|
|
+ await paintCursor('38;2;0;0;0;48;2;255;255;255')
|
|
|
+ await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
|
|
|
+ const whiteInDark = await cursorColors()
|
|
|
+ await paintCursor('0;7')
|
|
|
+ await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
|
|
|
+ for (const shape of [4, 6]) {
|
|
|
+ await paintCursor('38;5;51;48;5;16', shape)
|
|
|
+ await expect.poll(async () => shape === 4 ? (await cursorColors()).border : (await cursorColors()).shadow).toContain('rgb(249, 250, 251)')
|
|
|
+ }
|
|
|
+ await paintCursor('38;2;0;0;0;48;2;255;255;255', 1)
|
|
|
+ await page.addStyleTag({ content: '.xterm-cursor { animation-delay: -0.1s !important; animation-play-state: paused !important; }' })
|
|
|
+ await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
|
|
|
+ await page.addStyleTag({ content: '.xterm-cursor { animation-delay: -0.6s !important; }' })
|
|
|
+ await expect.poll(async () => (await cursorColors()).background).toBe('rgb(255, 255, 255)')
|
|
|
+ await page.locator('.xterm-helper-textarea:visible').evaluate((element) =>{ element.blur() })
|
|
|
+ await expect.poll(async () => (await cursorColors()).outline).toBe('rgb(0, 0, 0)')
|
|
|
+ await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/colors.expected.md', import.meta.url)),
|
|
|
+ JSON.stringify({ lightText, customLight, ronLight, whiteInDark }, null, 2), webSnapshotMode())
|
|
|
+ expect(handles).toHaveLength(1)
|
|
|
+ expect(tripwire.pageErrors).toEqual([])
|
|
|
+ })
|
|
|
+
|
|
|
it('follows light, dark and system themes while preserving the running shell and its output', async () => {
|
|
|
await page.emulateMedia({ colorScheme: 'light' })
|
|
|
await openTerminal(page)
|
|
|
@@ -98,31 +196,21 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
|
|
|
foreground: getComputedStyle(rows).color,
|
|
|
}
|
|
|
})
|
|
|
- const selectTheme = async (name: string) => {
|
|
|
- await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
|
|
- const dialog = page.getByRole('dialog', { name: 'Settings' })
|
|
|
- const [response] = await Promise.all([
|
|
|
- page.waitForResponse(candidate => new URL(candidate.url()).pathname === '/api/settings/mutate' && candidate.request().method() === 'POST'),
|
|
|
- dialog.getByRole('button', { name, exact: true }).click(),
|
|
|
- ])
|
|
|
- expect(response.ok()).toBe(true)
|
|
|
- await page.keyboard.press('Escape')
|
|
|
- await dialog.waitFor({ state: 'hidden' })
|
|
|
- }
|
|
|
+
|
|
|
const light = await readColors()
|
|
|
expect(light.viewport).toBe(light.surface)
|
|
|
expect(light.underlay).toBe(light.surface)
|
|
|
await terminal.screenshot({ path: `${shots}/theme-light.png`, animations: 'disabled' })
|
|
|
- await selectTheme('Dark')
|
|
|
+ await selectTerminalTheme(page, 'Dark')
|
|
|
await expect.poll(async () => (await readColors()).viewport).not.toBe(light.viewport)
|
|
|
const dark = await readColors()
|
|
|
expect(dark.viewport).toBe(dark.surface)
|
|
|
expect(dark.underlay).toBe(dark.surface)
|
|
|
expect(dark.foreground).not.toBe(light.foreground)
|
|
|
await terminal.screenshot({ path: `${shots}/theme-dark.png`, animations: 'disabled' })
|
|
|
- await selectTheme('Light')
|
|
|
+ await selectTerminalTheme(page, 'Light')
|
|
|
await expect.poll(readColors).toEqual(light)
|
|
|
- await selectTheme('System')
|
|
|
+ await selectTerminalTheme(page, 'System')
|
|
|
await page.emulateMedia({ colorScheme: 'dark' })
|
|
|
await expect.poll(readColors).toEqual(dark)
|
|
|
await page.emulateMedia({ colorScheme: 'light' })
|
|
|
@@ -282,3 +370,13 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
|
|
|
})
|
|
|
|
|
|
})
|
|
|
+
|
|
|
+function contrastRatio(first: string, second: string): number {
|
|
|
+ const luminance = (color: string) => {
|
|
|
+ const [r, g, b] = color.match(/[\d.]+/gu)!.map(Number).map(channel => channel / 255)
|
|
|
+ .map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)
|
|
|
+ return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
|
|
|
+ }
|
|
|
+ const a = luminance(first), b = luminance(second)
|
|
|
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
|
|
|
+}
|