test(vapor): vapor todomvc e2e test

This commit is contained in:
Evan You 2025-02-08 21:33:40 +08:00
parent 8549a243a5
commit ba0594de0b
No known key found for this signature in database
GPG Key ID: 00E9AB7A6704CE0A
7 changed files with 458 additions and 225 deletions

View File

@ -80,6 +80,32 @@ jobs:
- name: verify treeshaking
run: node scripts/verify-treeshaking.js
e2e-vapor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup cache for Chromium binary
uses: actions/cache@v4
with:
path: ~/.cache/puppeteer
key: chromium-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install pnpm
uses: pnpm/action-setup@v4.0.0
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.node-version'
cache: 'pnpm'
- run: pnpm install
- run: node node_modules/puppeteer/install.mjs
- name: Run e2e tests
run: pnpm run test-e2e-vapor
lint-and-test-dts:
runs-on: ubuntu-latest
env:

View File

@ -1,223 +0,0 @@
import puppeteer, {
type Browser,
type ClickOptions,
type LaunchOptions,
type Page,
} from 'puppeteer'
export const E2E_TIMEOUT: number = 30 * 1000
const puppeteerOptions: LaunchOptions = {
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
headless: true,
}
const maxTries = 30
export const timeout = (n: number): Promise<any> =>
new Promise(r => setTimeout(r, n))
export async function expectByPolling(
poll: () => Promise<any>,
expected: string,
): Promise<void> {
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)
}
}
}
interface PuppeteerUtils {
page: () => Page
click(selector: string, options?: ClickOptions): Promise<void>
count(selector: string): Promise<number>
text(selector: string): Promise<string | null>
value(selector: string): Promise<string>
html(selector: string): Promise<string>
classList(selector: string): Promise<string[]>
style(selector: string, property: keyof CSSStyleDeclaration): Promise<any>
children(selector: string): Promise<any[]>
isVisible(selector: string): Promise<boolean>
isChecked(selector: string): Promise<boolean>
isFocused(selector: string): Promise<boolean>
setValue(selector: string, value: string): Promise<any>
typeValue(selector: string, value: string): Promise<any>
enterValue(selector: string, value: string): Promise<any>
clearValue(selector: string): Promise<any>
timeout(time: number): Promise<any>
nextFrame(): Promise<any>
}
export function setupPuppeteer(args?: string[]): PuppeteerUtils {
let browser: Browser
let page: Page
const resolvedOptions = args
? {
...puppeteerOptions,
args: [...puppeteerOptions.args!, ...args],
}
: puppeteerOptions
beforeAll(async () => {
browser = await puppeteer.launch(resolvedOptions)
}, 20000)
beforeEach(async () => {
page = await browser.newPage()
await page.evaluateOnNewDocument(() => {
localStorage.clear()
})
page.on('console', e => {
if (e.type() === 'error') {
console.error(`Error from Puppeteer-loaded page:\n`, e.text())
}
})
})
afterEach(async () => {
await page.close()
})
afterAll(async () => {
await browser.close()
})
async function click(
selector: string,
options?: ClickOptions,
): Promise<void> {
await page.click(selector, options)
}
async function count(selector: string): Promise<number> {
return (await page.$$(selector)).length
}
async function text(selector: string): Promise<string | null> {
return page.$eval(selector, node => node.textContent)
}
async function value(selector: string): Promise<string> {
return page.$eval(selector, node => (node as HTMLInputElement).value)
}
async function html(selector: string): Promise<string> {
return page.$eval(selector, node => node.innerHTML)
}
async function classList(selector: string): Promise<string[]> {
return page.$eval(selector, (node: any) => [...node.classList])
}
async function children(selector: string): Promise<any[]> {
return page.$eval(selector, (node: any) => [...node.children])
}
async function style(
selector: string,
property: keyof CSSStyleDeclaration,
): Promise<any> {
return await page.$eval(
selector,
(node, property) => {
return window.getComputedStyle(node)[property]
},
property,
)
}
async function isVisible(selector: string): Promise<boolean> {
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,
style,
children,
isVisible,
isChecked,
isFocused,
setValue,
typeValue,
enterValue,
clearValue,
timeout,
nextFrame,
}
}

View File

@ -1 +1,195 @@
test('bar', () => {})
import path from 'node:path'
import {
E2E_TIMEOUT,
setupPuppeteer,
} from '../../../packages/vue/__tests__/e2e/e2eUtils'
import connect from 'connect'
import sirv from 'sirv'
describe('e2e: todomvc', () => {
const {
page,
click,
isVisible,
count,
text,
value,
isChecked,
isFocused,
classList,
enterValue,
clearValue,
timeout,
} = setupPuppeteer()
let server: any
const port = '8194'
beforeAll(() => {
server = connect()
.use(sirv(path.resolve(import.meta.dirname, '../dist')))
.listen(port)
process.on('SIGTERM', () => server && server.close())
})
afterAll(() => {
server.close()
})
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')
}
test(
'vapor',
async () => {
const baseUrl = `http://localhost:${port}/todomvc/`
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')
await timeout(1)
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')
await timeout(1)
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')
await timeout(1)
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 timeout(1)
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)
},
E2E_TIMEOUT,
)
})

View File

@ -25,7 +25,7 @@ describe('vdom / vapor interop', () => {
test(
'should work',
async () => {
const baseUrl = `http://localhost:${port}/interop`
const baseUrl = `http://localhost:${port}/interop/`
await page().goto(baseUrl)
expect(await text('.vapor > h2')).toContain('Vapor component in VDOM')

View File

@ -0,0 +1,230 @@
<script setup vapor>
import {
reactive,
computed,
watchEffect,
onMounted,
onUnmounted,
next,
nextTick,
} from 'vue'
const STORAGE_KEY = 'todos-vuejs-3.x'
const todoStorage = {
fetch() {
const todos = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
todos.forEach((todo, index) => {
todo.id = index
})
todoStorage.uid = todos.length
return todos
},
save(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
},
}
const filters = {
all(todos) {
return todos
},
active(todos) {
return todos.filter(todo => {
return !todo.completed
})
},
completed(todos) {
return todos.filter(function (todo) {
return todo.completed
})
},
}
function pluralize(n) {
return n === 1 ? 'item' : 'items'
}
const state = reactive({
todos: todoStorage.fetch(),
editedTodo: null,
newTodo: '',
beforeEditCache: '',
visibility: 'all',
remaining: computed(() => {
return filters.active(state.todos).length
}),
remainingText: computed(() => {
return ` ${pluralize(state.remaining)} left`
}),
filteredTodos: computed(() => {
return filters[state.visibility](state.todos)
}),
allDone: computed({
get: function () {
return state.remaining === 0
},
set: function (value) {
state.todos.forEach(todo => {
todo.completed = value
})
},
}),
})
watchEffect(() => {
todoStorage.save(state.todos)
})
onMounted(() => {
window.addEventListener('hashchange', onHashChange)
onHashChange()
})
onUnmounted(() => {
window.removeEventListener('hashchange', onHashChange)
})
function onHashChange() {
const visibility = window.location.hash.replace(/#\/?/, '')
if (filters[visibility]) {
state.visibility = visibility
} else {
window.location.hash = ''
state.visibility = 'all'
}
}
function addTodo() {
const value = state.newTodo && state.newTodo.trim()
if (!value) {
return
}
state.todos.push({
id: todoStorage.uid++,
title: value,
completed: false,
})
state.newTodo = ''
}
function removeTodo(todo) {
state.todos.splice(state.todos.indexOf(todo), 1)
}
function editTodo(todo) {
state.beforeEditCache = todo.title
state.editedTodo = todo
}
function doneEdit(todo) {
if (!state.editedTodo) {
return
}
state.editedTodo = null
todo.title = todo.title.trim()
if (!todo.title) {
removeTodo(todo)
}
}
function cancelEdit(todo) {
state.editedTodo = null
todo.title = state.beforeEditCache
}
function removeCompleted() {
state.todos = filters.active(state.todos)
}
// vapor custom directive
const vTodoFocus = (el, value) => () => {
if (value()) nextTick(() => el.focus())
}
</script>
<template>
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<input
class="new-todo"
autofocus
autocomplete="off"
placeholder="What needs to be done?"
v-model="state.newTodo"
@keyup.enter="addTodo"
/>
</header>
<section class="main" v-show="state.todos.length">
<input
id="toggle-all"
class="toggle-all"
type="checkbox"
v-model="state.allDone"
/>
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list">
<li
v-for="todo in state.filteredTodos"
class="todo"
:key="todo.id"
:class="{
completed: todo.completed,
editing: todo === state.editedTodo,
}"
>
<div class="view">
<input class="toggle" type="checkbox" v-model="todo.completed" />
<label @dblclick="editTodo(todo)">{{ todo.title }}</label>
<button class="destroy" @click="removeTodo(todo)"></button>
</div>
<input
class="edit"
type="text"
v-model="todo.title"
v-todo-focus="todo === state.editedTodo"
@blur="doneEdit(todo)"
@keyup.enter="doneEdit(todo)"
@keyup.escape="cancelEdit(todo)"
/>
</li>
</ul>
</section>
<footer class="footer" v-show="state.todos.length">
<span class="todo-count">
<strong>{{ state.remaining }}</strong>
<span>{{ state.remainingText }}</span>
</span>
<ul class="filters">
<li>
<a href="#/all" :class="{ selected: state.visibility === 'all' }"
>All</a
>
</li>
<li>
<a
href="#/active"
:class="{ selected: state.visibility === 'active' }"
>Active</a
>
</li>
<li>
<a
href="#/completed"
:class="{ selected: state.visibility === 'completed' }"
>Completed</a
>
</li>
</ul>
<button
class="clear-completed"
@click="removeCompleted"
v-show="state.todos.length > state.remaining"
>
Clear completed
</button>
</footer>
</section>
</template>

View File

@ -1 +1,2 @@
<script type="module" src="./main.ts"></script>
<div id="app"></div>

View File

@ -0,0 +1,5 @@
import { createVaporApp } from 'vue'
import App from './App.vue'
import 'todomvc-app-css/index.css'
createVaporApp(App).mount('#app')