diff --git a/package.json b/package.json index aa1f3b59b..2feb62ad7 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "format-check": "prettier --check --cache .", "test": "vitest", "test-unit": "vitest -c vitest.unit.config.ts", - "test-e2e": "node scripts/build.js vue -f global -d && vitest -c vitest.e2e.config.ts", + "test-e2e": "node scripts/build.js vue vue-vapor -f global -d && vitest -c vitest.e2e.config.ts", "test-dts": "run-s build-dts test-dts-only", "test-dts-only": "tsc -p packages/dts-built-test/tsconfig.json && tsc -p ./packages/dts-test/tsconfig.test.json", "test-coverage": "vitest -c vitest.unit.config.ts --coverage", diff --git a/packages/vue-vapor/__tests__/e2e/e2eUtils.ts b/packages/vue-vapor/__tests__/e2e/e2eUtils.ts new file mode 100644 index 000000000..6e98ffc1d --- /dev/null +++ b/packages/vue-vapor/__tests__/e2e/e2eUtils.ts @@ -0,0 +1,181 @@ +import puppeteer, { + type Browser, + type ClickOptions, + type Page, + type PuppeteerLaunchOptions, +} from 'puppeteer' + +export const E2E_TIMEOUT = 30 * 1000 + +const puppeteerOptions: PuppeteerLaunchOptions = { + args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [], + headless: true, +} + +const maxTries = 30 +export const timeout = (n: number) => new Promise(r => setTimeout(r, n)) + +export async function expectByPolling( + poll: () => Promise, + expected: string, +) { + for (let tries = 0; tries < maxTries; tries++) { + const actual = (await poll()) || '' + if (actual.indexOf(expected) > -1 || tries === maxTries - 1) { + expect(actual).toMatch(expected) + break + } else { + await timeout(50) + } + } +} + +export function setupPuppeteer() { + let browser: Browser + let page: Page + + beforeAll(async () => { + browser = await puppeteer.launch(puppeteerOptions) + }, 20000) + + beforeEach(async () => { + page = await browser.newPage() + + await page.evaluateOnNewDocument(() => { + localStorage.clear() + }) + + page.on('console', e => { + if (e.type() === 'error') { + const err = e.args()[0] + console.error( + `Error from Puppeteer-loaded page:\n`, + err.remoteObject().description, + ) + } + }) + }) + + afterEach(async () => { + await page.close() + }) + + afterAll(async () => { + await browser.close() + }) + + async function click(selector: string, options?: ClickOptions) { + await page.click(selector, options) + } + + async function count(selector: string) { + return (await page.$$(selector)).length + } + + async function text(selector: string) { + return await page.$eval(selector, node => node.textContent) + } + + async function value(selector: string) { + return await page.$eval(selector, node => (node as HTMLInputElement).value) + } + + async function html(selector: string) { + return await page.$eval(selector, node => node.innerHTML) + } + + async function classList(selector: string) { + return await page.$eval(selector, (node: any) => [...node.classList]) + } + + async function children(selector: string) { + return await page.$eval(selector, (node: any) => [...node.children]) + } + + async function isVisible(selector: string) { + const display = await page.$eval(selector, node => { + return window.getComputedStyle(node).display + }) + return display !== 'none' + } + + async function isChecked(selector: string) { + return await page.$eval( + selector, + node => (node as HTMLInputElement).checked, + ) + } + + async function isFocused(selector: string) { + return await page.$eval(selector, node => node === document.activeElement) + } + + async function setValue(selector: string, value: string) { + await page.$eval( + selector, + (node, value) => { + ;(node as HTMLInputElement).value = value as string + node.dispatchEvent(new Event('input')) + }, + value, + ) + } + + async function typeValue(selector: string, value: string) { + const el = (await page.$(selector))! + await el.evaluate(node => ((node as HTMLInputElement).value = '')) + await el.type(value) + } + + async function enterValue(selector: string, value: string) { + const el = (await page.$(selector))! + await el.evaluate(node => ((node as HTMLInputElement).value = '')) + await el.type(value) + await el.press('Enter') + } + + async function clearValue(selector: string) { + return await page.$eval( + selector, + node => ((node as HTMLInputElement).value = ''), + ) + } + + function timeout(time: number) { + return page.evaluate(time => { + return new Promise(r => { + setTimeout(r, time) + }) + }, time) + } + + function nextFrame() { + return page.evaluate(() => { + return new Promise(resolve => { + requestAnimationFrame(() => { + requestAnimationFrame(resolve) + }) + }) + }) + } + + return { + page: () => page, + click, + count, + text, + value, + html, + classList, + children, + isVisible, + isChecked, + isFocused, + setValue, + typeValue, + enterValue, + clearValue, + timeout, + nextFrame, + } +} diff --git a/packages/vue-vapor/__tests__/e2e/todomvc.spec.ts b/packages/vue-vapor/__tests__/e2e/todomvc.spec.ts new file mode 100644 index 000000000..c26079c06 --- /dev/null +++ b/packages/vue-vapor/__tests__/e2e/todomvc.spec.ts @@ -0,0 +1,177 @@ +import path from 'node:path' +import { E2E_TIMEOUT, setupPuppeteer } from './e2eUtils' + +describe('e2e: todomvc', () => { + const { + page, + click, + isVisible, + count, + text, + value, + isChecked, + isFocused, + classList, + enterValue, + clearValue, + } = setupPuppeteer() + + async function removeItemAt(n: number) { + const item = (await page().$('.todo:nth-child(' + n + ')'))! + const itemBBox = (await item.boundingBox())! + await page().mouse.move(itemBBox.x + 10, itemBBox.y + 10) + await click('.todo:nth-child(' + n + ') .destroy') + } + + async function testTodomvc(apiType: 'classic' | 'composition') { + let baseUrl = `../../examples/${apiType}/todomvc.html` + baseUrl = `file://${path.resolve(__dirname, baseUrl)}` + + await page().goto(baseUrl) + expect(await isVisible('.main')).toBe(false) + expect(await isVisible('.footer')).toBe(false) + expect(await count('.filters .selected')).toBe(1) + expect(await text('.filters .selected')).toBe('All') + expect(await count('.todo')).toBe(0) + + await enterValue('.new-todo', 'test') + expect(await count('.todo')).toBe(1) + expect(await isVisible('.todo .edit')).toBe(false) + expect(await text('.todo label')).toBe('test') + expect(await text('.todo-count strong')).toBe('1') + expect(await isChecked('.todo .toggle')).toBe(false) + expect(await isVisible('.main')).toBe(true) + expect(await isVisible('.footer')).toBe(true) + expect(await isVisible('.clear-completed')).toBe(false) + expect(await value('.new-todo')).toBe('') + + await enterValue('.new-todo', 'test2') + expect(await count('.todo')).toBe(2) + expect(await text('.todo:nth-child(2) label')).toBe('test2') + expect(await text('.todo-count strong')).toBe('2') + + // toggle + await click('.todo .toggle') + expect(await count('.todo.completed')).toBe(1) + expect(await classList('.todo:nth-child(1)')).toContain('completed') + expect(await text('.todo-count strong')).toBe('1') + expect(await isVisible('.clear-completed')).toBe(true) + + await enterValue('.new-todo', 'test3') + expect(await count('.todo')).toBe(3) + expect(await text('.todo:nth-child(3) label')).toBe('test3') + expect(await text('.todo-count strong')).toBe('2') + + await enterValue('.new-todo', 'test4') + await enterValue('.new-todo', 'test5') + expect(await count('.todo')).toBe(5) + expect(await text('.todo-count strong')).toBe('4') + + // toggle more + await click('.todo:nth-child(4) .toggle') + await click('.todo:nth-child(5) .toggle') + expect(await count('.todo.completed')).toBe(3) + expect(await text('.todo-count strong')).toBe('2') + + // remove + await removeItemAt(1) + expect(await count('.todo')).toBe(4) + expect(await count('.todo.completed')).toBe(2) + expect(await text('.todo-count strong')).toBe('2') + await removeItemAt(2) + expect(await count('.todo')).toBe(3) + expect(await count('.todo.completed')).toBe(2) + expect(await text('.todo-count strong')).toBe('1') + + // remove all + await click('.clear-completed') + expect(await count('.todo')).toBe(1) + expect(await text('.todo label')).toBe('test2') + expect(await count('.todo.completed')).toBe(0) + expect(await text('.todo-count strong')).toBe('1') + expect(await isVisible('.clear-completed')).toBe(false) + + // prepare to test filters + await enterValue('.new-todo', 'test') + await enterValue('.new-todo', 'test') + await click('.todo:nth-child(2) .toggle') + await click('.todo:nth-child(3) .toggle') + + // active filter + await click('.filters li:nth-child(2) a') + expect(await count('.todo')).toBe(1) + expect(await count('.todo.completed')).toBe(0) + // add item with filter active + await enterValue('.new-todo', 'test') + expect(await count('.todo')).toBe(2) + + // completed filter + await click('.filters li:nth-child(3) a') + expect(await count('.todo')).toBe(2) + expect(await count('.todo.completed')).toBe(2) + + // filter on page load + await page().goto(`${baseUrl}#active`) + expect(await count('.todo')).toBe(2) + expect(await count('.todo.completed')).toBe(0) + expect(await text('.todo-count strong')).toBe('2') + + // completed on page load + await page().goto(`${baseUrl}#completed`) + expect(await count('.todo')).toBe(2) + expect(await count('.todo.completed')).toBe(2) + expect(await text('.todo-count strong')).toBe('2') + + // toggling with filter active + await click('.todo .toggle') + expect(await count('.todo')).toBe(1) + await click('.filters li:nth-child(2) a') + expect(await count('.todo')).toBe(3) + await click('.todo .toggle') + expect(await count('.todo')).toBe(2) + + // editing triggered by blur + await click('.filters li:nth-child(1) a') + await click('.todo:nth-child(1) label', { clickCount: 2 }) + expect(await count('.todo.editing')).toBe(1) + expect(await isFocused('.todo:nth-child(1) .edit')).toBe(true) + await clearValue('.todo:nth-child(1) .edit') + await page().type('.todo:nth-child(1) .edit', 'edited!') + await click('.new-todo') // blur + expect(await count('.todo.editing')).toBe(0) + expect(await text('.todo:nth-child(1) label')).toBe('edited!') + + // editing triggered by enter + await click('.todo label', { clickCount: 2 }) + await enterValue('.todo:nth-child(1) .edit', 'edited again!') + expect(await count('.todo.editing')).toBe(0) + expect(await text('.todo:nth-child(1) label')).toBe('edited again!') + + // cancel + await click('.todo label', { clickCount: 2 }) + await clearValue('.todo:nth-child(1) .edit') + await page().type('.todo:nth-child(1) .edit', 'edited!') + await page().keyboard.press('Escape') + expect(await count('.todo.editing')).toBe(0) + expect(await text('.todo:nth-child(1) label')).toBe('edited again!') + + // empty value should remove + await click('.todo label', { clickCount: 2 }) + await enterValue('.todo:nth-child(1) .edit', ' ') + expect(await count('.todo')).toBe(3) + + // toggle all + await click('.toggle-all+label') + expect(await count('.todo.completed')).toBe(3) + await click('.toggle-all+label') + expect(await count('.todo:not(.completed)')).toBe(3) + } + + test( + 'composition', + async () => { + await testTodomvc('composition') + }, + E2E_TIMEOUT, + ) +}) diff --git a/packages/vue-vapor/examples/composition/todomvc.html b/packages/vue-vapor/examples/composition/todomvc.html new file mode 100644 index 000000000..47faa6412 --- /dev/null +++ b/packages/vue-vapor/examples/composition/todomvc.html @@ -0,0 +1,224 @@ + + + +
+ + diff --git a/playground/src/example-todomvc.vue b/playground/src/example-todomvc.vue new file mode 100644 index 000000000..1ee5ac2e7 --- /dev/null +++ b/playground/src/example-todomvc.vue @@ -0,0 +1,249 @@ + + + diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 90a67d229..2436baf2b 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -8,6 +8,9 @@ export default mergeConfig(config, { singleThread: !!process.env.CI, }, }, - include: ['packages/vue/__tests__/e2e/*.spec.ts'], + include: [ + 'packages/vue/__tests__/e2e/*.spec.ts', + 'packages/vue-vapor/__tests__/e2e/*.spec.ts', + ], }, })