Skip to content

Commit 123dadf

Browse files
committed
chore(inlay): playwright CT
1 parent d48c109 commit 123dadf

15 files changed

Lines changed: 507 additions & 36 deletions

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,10 @@ yarn-error.log*
2525
*storybook.log
2626

2727
/dist
28+
29+
# Playwright
30+
node_modules/
31+
/test-results/
32+
/playwright-report/
33+
/blob-report/
34+
/playwright/.cache/

bun.lock

Lines changed: 136 additions & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
"import": "./dist/timeslice/index.es.js",
2222
"types": "./dist/timeslice.d.ts",
2323
"require": "./dist/timeslice/index.cjs.js"
24+
},
25+
"./inlay": {
26+
"import": "./dist/inlay/index.es.js",
27+
"types": "./dist/inlay.d.ts",
28+
"require": "./dist/inlay/index.cjs.js"
2429
}
2530
},
2631
"typesVersions": {
@@ -61,11 +66,15 @@
6166
"test": "vitest",
6267
"test:run": "vitest run",
6368
"test:ui": "vitest --ui",
64-
"lint-staged": "lint-staged"
69+
"lint-staged": "lint-staged",
70+
"test:ct": "playwright test -c playwright-ct.config.mts",
71+
"test:ct:ui": "playwright test -c playwright-ct.config.mts --ui",
72+
"playwright:install": "playwright install --with-deps",
73+
"test-ct": "playwright test -c playwright-ct.config.mts"
6574
},
6675
"peerDependencies": {
67-
"react": "^18.0.0 || ^19.0.0",
68-
"react-dom": "^18.0.0 || ^19.0.0"
76+
"react": "^19.0.0",
77+
"react-dom": "^19.0.0"
6978
},
7079
"dependencies": {
7180
"@formatjs/intl-datetimeformat": "^6.18.0",
@@ -82,6 +91,8 @@
8291
},
8392
"devDependencies": {
8493
"@heroicons/react": "^2.2.0",
94+
"@playwright/experimental-ct-react": "^1.54.2",
95+
"@playwright/test": "^1.54.2",
8596
"@semantic-release/changelog": "^6.0.3",
8697
"@semantic-release/commit-analyzer": "^13.0.1",
8798
"@semantic-release/git": "^10.0.1",
@@ -96,11 +107,12 @@
96107
"@testing-library/jest-dom": "^6.6.3",
97108
"@testing-library/react": "^16.3.0",
98109
"@testing-library/user-event": "^13.5.0",
110+
"@types/node": "^24.2.1",
99111
"@types/react": "^19.0.10",
100112
"@types/react-dom": "^19.0.4",
101113
"@typescript-eslint/eslint-plugin": "^8.32.0",
102114
"@typescript-eslint/parser": "^8.32.0",
103-
"@vitejs/plugin-react-swc": "^3.9.0",
115+
"@vitejs/plugin-react-swc": "^4.0.0",
104116
"@vitest/ui": "^3.1.3",
105117
"ajv": "^8.17.1",
106118
"autoprefixer": "^10.4.21",

playwright-ct.config.mts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { defineConfig, devices } from '@playwright/experimental-ct-react'
2+
import react from '@vitejs/plugin-react-swc'
3+
4+
export default defineConfig({
5+
testDir: './',
6+
testMatch: /.*\.spec\.tsx?$/,
7+
snapshotDir: './__snapshots__',
8+
timeout: 10 * 1000,
9+
fullyParallel: true,
10+
forbidOnly: !!process.env.CI,
11+
retries: process.env.CI ? 2 : 0,
12+
workers: process.env.CI ? 1 : undefined,
13+
14+
reporter: 'list', // never open the HTML reporter
15+
16+
use: {
17+
trace: 'on-first-retry',
18+
ctPort: 3100,
19+
20+
ctViteConfig: {
21+
plugins: [react()]
22+
}
23+
},
24+
25+
projects: [
26+
{
27+
name: 'chromium',
28+
use: { ...devices['Desktop Chrome'] }
29+
},
30+
{
31+
name: 'firefox',
32+
use: { ...devices['Desktop Firefox'] }
33+
},
34+
{
35+
name: 'webkit',
36+
use: { ...devices['Desktop Safari'] }
37+
}
38+
]
39+
})

playwright/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Testing Page</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="./index.tsx"></script>
11+
</body>
12+
</html>

playwright/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Import styles, initialize component theme here.
2+
// import '../src/common.css';
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import React from 'react'
2+
import { Root as Inlay, Token } from '../..'
3+
4+
export function ControlledTokenInlay({ initial }: { initial: string }) {
5+
const [value, setValue] = React.useState(initial)
6+
return (
7+
<Inlay value={value} onChange={setValue} data-testid="root">
8+
{value.includes('@x') ? (
9+
<Token value="@x">
10+
<span>@x</span>
11+
</Token>
12+
) : (
13+
<span />
14+
)}
15+
</Inlay>
16+
)
17+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { test, expect } from '@playwright/experimental-ct-react'
2+
import { Root as Inlay } from '../'
3+
4+
test('basic typing/backspace', async ({ mount, page }) => {
5+
await mount(
6+
<Inlay defaultValue={'ab'} data-testid="root">
7+
<span />
8+
</Inlay>
9+
)
10+
11+
const root = page.getByTestId('root')
12+
await expect(root).toHaveCount(1)
13+
14+
const ed = page.getByRole('textbox')
15+
await ed.click()
16+
await page.keyboard.press('ArrowRight')
17+
await page.keyboard.press('ArrowRight')
18+
await page.keyboard.type('c')
19+
await expect(ed).toHaveText('abc')
20+
for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowRight')
21+
await page.keyboard.press('Backspace')
22+
await expect(ed).toHaveText('ab')
23+
})
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { test, expect } from '@playwright/experimental-ct-react'
2+
import type { Locator } from '@playwright/test'
3+
import { Root as Inlay } from '../'
4+
5+
// Chromium-only: use CDP to simulate IME composition end-to-end
6+
const composeWithCDP = async (
7+
page: import('@playwright/test').Page,
8+
text: string
9+
) => {
10+
const client = await page.context().newCDPSession(page)
11+
await client.send('Input.imeSetComposition', {
12+
text,
13+
selectionStart: text.length,
14+
selectionEnd: text.length
15+
})
16+
await client.send('Input.insertText', { text })
17+
await client.send('Input.imeSetComposition', {
18+
text: '',
19+
selectionStart: 0,
20+
selectionEnd: 0
21+
})
22+
}
23+
24+
async function assertSingleTextOrSpanText(
25+
ed: Locator,
26+
expected: string
27+
): Promise<boolean> {
28+
return ed.evaluate((el: HTMLElement, exp: string) => {
29+
const kids: ChildNode[] = Array.from(el.childNodes)
30+
if (kids.length !== 1) return false
31+
const only: ChildNode = kids[0]
32+
if (only.nodeType === Node.TEXT_NODE) return el.textContent === exp
33+
if (only.nodeType === Node.ELEMENT_NODE) {
34+
const span = only as Element
35+
if (span.childNodes.length !== 1) return false
36+
const first = span.firstChild as ChildNode | null
37+
return (
38+
!!first && first.nodeType === Node.TEXT_NODE && el.textContent === exp
39+
)
40+
}
41+
return false
42+
}, expected)
43+
}
44+
45+
test.describe('IME composition via CDP (Chromium)', () => {
46+
test.skip(
47+
({ browserName }) => browserName !== 'chromium',
48+
'CDP IME APIs are Chromium-only'
49+
)
50+
51+
test('Space commit produces composed text and a trailing space', async ({
52+
mount,
53+
page
54+
}) => {
55+
await mount(
56+
<Inlay defaultValue={''} data-testid="root">
57+
{null}
58+
</Inlay>
59+
)
60+
const ed = page.getByRole('textbox')
61+
await ed.click()
62+
63+
await composeWithCDP(page, 'にほん')
64+
await page.keyboard.press('Space')
65+
66+
await expect(ed).toHaveText('にほん ')
67+
await expect(ed.locator('br')).toHaveCount(0)
68+
const ok = await assertSingleTextOrSpanText(ed, 'にほん ')
69+
expect(ok).toBe(true)
70+
})
71+
72+
test('Enter commit composes text and does not add a stray newline immediately', async ({
73+
mount,
74+
page
75+
}) => {
76+
await mount(
77+
<Inlay defaultValue={''} data-testid="root">
78+
{null}
79+
</Inlay>
80+
)
81+
const ed = page.getByRole('textbox')
82+
await ed.click()
83+
84+
await composeWithCDP(page, 'テスト')
85+
await page.keyboard.press('Enter')
86+
87+
await expect(ed).toHaveText('テスト')
88+
await expect(ed.locator('br')).toHaveCount(0)
89+
const ok = await assertSingleTextOrSpanText(ed, 'テスト')
90+
expect(ok).toBe(true)
91+
})
92+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { test, expect } from '@playwright/experimental-ct-react'
2+
import { Root as Inlay } from '../'
3+
4+
test.describe('Grapheme handling (CT)', () => {
5+
test('Backspace deletes an entire emoji grapheme cluster', async ({
6+
mount,
7+
page
8+
}) => {
9+
const cluster = '👍🏼'
10+
await mount(
11+
<Inlay defaultValue={cluster} data-testid="root">
12+
<span />
13+
</Inlay>
14+
)
15+
16+
const ed = page.getByRole('textbox')
17+
await ed.click()
18+
// Normalize caret to start by overshooting ArrowLeft
19+
for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft')
20+
// Move caret to end using ArrowRight (cluster is one grapheme)
21+
await page.keyboard.press('ArrowRight')
22+
23+
await page.keyboard.press('Backspace')
24+
await expect(ed).toHaveText('')
25+
})
26+
27+
test('Backspace with selection slicing through a grapheme removes the whole grapheme', async ({
28+
mount,
29+
page
30+
}) => {
31+
const cluster = '👍🏼'
32+
const text = `a${cluster}b`
33+
await mount(
34+
<Inlay defaultValue={text} data-testid="root">
35+
<span />
36+
</Inlay>
37+
)
38+
39+
const ed = page.getByRole('textbox')
40+
await ed.click()
41+
// Normalize caret to start by overshooting ArrowLeft
42+
for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft')
43+
// Move to just before the grapheme (after the initial 'a')
44+
await page.keyboard.press('ArrowRight')
45+
// Extend selection across exactly one grapheme
46+
await page.keyboard.press('Shift+ArrowRight')
47+
48+
await page.keyboard.press('Backspace')
49+
await expect(ed).toHaveText('ab')
50+
})
51+
52+
test('Backspace deletes entire flag grapheme (regional indicators) before caret', async ({
53+
mount,
54+
page
55+
}) => {
56+
const flag = '🇺🇸'
57+
await mount(
58+
<Inlay defaultValue={`a${flag}b`} data-testid="root">
59+
<span />
60+
</Inlay>
61+
)
62+
63+
const ed = page.getByRole('textbox')
64+
await ed.click()
65+
// Normalize caret to start by overshooting ArrowLeft
66+
for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft')
67+
// Move to after 'a' and then after the flag grapheme
68+
await page.keyboard.press('ArrowRight')
69+
await page.keyboard.press('ArrowRight')
70+
71+
await page.keyboard.press('Backspace')
72+
await expect(ed).toHaveText('ab')
73+
})
74+
75+
test('Backspace deletes composed character with combining mark as a single grapheme', async ({
76+
mount,
77+
page
78+
}) => {
79+
const composed = 'e\u0301'
80+
await mount(
81+
<Inlay defaultValue={composed} data-testid="root">
82+
<span />
83+
</Inlay>
84+
)
85+
86+
const ed = page.getByRole('textbox')
87+
await ed.click()
88+
// Normalize caret to start by overshooting ArrowLeft
89+
for (let i = 0; i < 10; i++) await page.keyboard.press('ArrowLeft')
90+
// Move to end (one grapheme)
91+
await page.keyboard.press('ArrowRight')
92+
93+
await page.keyboard.press('Backspace')
94+
await expect(ed).toHaveText('')
95+
})
96+
})

0 commit comments

Comments
 (0)