- 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:
+++ /dev/null
-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,
- }
-}
-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,
+ )
+})
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')
--- /dev/null
+<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>
<script type="module" src="./main.ts"></script>
+<div id="app"></div>
+import { createVaporApp } from 'vue'
+import App from './App.vue'
+import 'todomvc-app-css/index.css'
+
+createVaporApp(App).mount('#app')