ソースを参照

test(vue-vapor): todomvc e2e test (#115)

Rizumu Ayaka 1 年間 前
コミット
2e2f3e2b96

+ 1 - 1
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",

+ 181 - 0
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<any>,
+  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,
+  }
+}

+ 177 - 0
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,
+  )
+})

+ 224 - 0
packages/vue-vapor/examples/composition/todomvc.html

@@ -0,0 +1,224 @@
+<script src="../../dist/vue-vapor.global.js"></script>
+<link rel="stylesheet" href="../../../../node_modules/todomvc-app-css/index.css">
+
+<div id="app"></div>
+
+<script>
+const { createVaporApp, defineComponent, reactive, computed, watchEffect, onMounted, onUnmounted, nextTick } = VueVapor
+
+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 _sfc_main = defineComponent({
+  setup() {
+    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)
+    }
+
+    async function editTodo(todo) {
+      state.beforeEditCache = todo.title
+      state.editedTodo = todo
+      await nextTick()
+      document.getElementById(`todo-${todo.id}-input`).focus()
+    }
+
+    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)
+    }
+
+    return {
+      state,
+      addTodo,
+      removeTodo,
+      editTodo,
+      doneEdit,
+      cancelEdit,
+      removeCompleted,
+    }
+  },
+})
+
+const { children: _children, vModelText: _vModelText, withDirectives: _withDirectives, vShow: _vShow, next: _next, delegate: _delegate, on: _on, setDynamicProp: _setDynamicProp, setText: _setText, setClass: _setClass, renderEffect: _renderEffect, createFor: _createFor, insert: _insert, delegateEvents: _delegateEvents, template: _template } = VueVapor
+const t0 = _template("<li><div class=\"view\"><input class=\"toggle\" type=\"checkbox\"><label></label><button class=\"destroy\"></button></div><input class=\"edit\" type=\"text\"></li>")
+const t1 = _template("<section class=\"todoapp\"><header class=\"header\"><h1>todos</h1><input class=\"new-todo\" autofocus autocomplete=\"off\" placeholder=\"What needs to be done?\"></header><section class=\"main\"><input id=\"toggle-all\" class=\"toggle-all\" type=\"checkbox\"><label for=\"toggle-all\">Mark all as complete</label><ul class=\"todo-list\"></ul></section><footer class=\"footer\"><span class=\"todo-count\"><strong></strong><span></span></span><ul class=\"filters\"><li><a href=\"#/all\">All</a></li><li><a href=\"#/active\">Active</a></li><li><a href=\"#/completed\">Completed</a></li></ul><button class=\"clear-completed\"> Clear completed </button></footer></section>")
+_delegateEvents("keyup", "dblclick", "click")
+
+function _sfc_render(_ctx) {
+  const n18 = t1()
+  const n0 = _children(n18, 0, 1)
+  _withDirectives(n0, [[_vModelText, () => _ctx.state.newTodo]])
+  const n10 = _children(n18, 1)
+  _withDirectives(n10, [[_vShow, () => _ctx.state.todos.length]])
+  const n1 = n10.firstChild
+  const n9 = _next(n1, 2)
+  const n17 = n10.nextSibling
+  _withDirectives(n17, [[_vShow, () => _ctx.state.todos.length]])
+  const n11 = _children(n17, 0, 0)
+  const n12 = n11.nextSibling
+  const n13 = _children(n17, 1, 0, 0)
+  const n14 = _children(n17, 1, 1, 0)
+  const n15 = _children(n17, 1, 2, 0)
+  const n16 = _children(n17, 2)
+  _withDirectives(n16, [[_vShow, () => _ctx.state.todos.length > _ctx.state.remaining]])
+  _delegate(n0, "update:modelValue", () => $event => (_ctx.state.newTodo = $event))
+  _delegate(n0, "keyup", () => _ctx.addTodo, {
+    keys: ["enter"]
+  })
+  _on(n1, "change", () => $event => (_ctx.state.allDone = $event.target.checked))
+  const n2 = _createFor(() => (_ctx.state.filteredTodos), (_block) => {
+    const n8 = t0()
+    const n4 = _children(n8, 0, 0)
+    const n5 = n4.nextSibling
+    const n6 = n5.nextSibling
+    const n7 = _children(n8, 1)
+    _on(n4, "change", () => $event => (_block.s[0].completed = $event.target.checked))
+    _delegate(n5, "dblclick", () => $event => (_ctx.editTodo(_block.s[0])))
+    _delegate(n6, "click", () => $event => (_ctx.removeTodo(_block.s[0])))
+    _on(n7, "input", () => $event => (_block.s[0].title = $event.target.value))
+    _on(n7, "blur", () => $event => (_ctx.doneEdit(_block.s[0])))
+    _delegate(n7, "keyup", () => $event => (_ctx.doneEdit(_block.s[0])), {
+      keys: ["enter"]
+    })
+    _delegate(n7, "keyup", () => $event => (_ctx.cancelEdit(_block.s[0])), {
+      keys: ["escape"]
+    })
+    const _updateEffect = () => {
+      const [todo] = _block.s
+      _setDynamicProp(n4, "checked", todo.completed)
+      _setText(n5, todo.title)
+      _setDynamicProp(n7, "value", todo.title)
+      _setDynamicProp(n7, "id", `todo-${todo.id}-input`)
+      _setClass(n8, ["todo", {
+        completed: todo.completed,
+        editing: todo === _ctx.state.editedTodo,
+      }])
+    }
+    _renderEffect(_updateEffect)
+    return [n8, _updateEffect]
+  }, (todo) => (todo.id))
+  _insert(n2, n9)
+  _delegate(n16, "click", () => _ctx.removeCompleted)
+  _renderEffect(() => _setDynamicProp(n1, "checked", _ctx.state.allDone))
+  _renderEffect(() => _setText(n11, _ctx.state.remaining))
+  _renderEffect(() => _setText(n12, _ctx.state.remainingText))
+  _renderEffect(() => _setClass(n13, { selected: _ctx.state.visibility === 'all' }))
+  _renderEffect(() => _setClass(n14, { selected: _ctx.state.visibility === 'active' }))
+  _renderEffect(() => _setClass(n15, { selected: _ctx.state.visibility === 'completed' }))
+  return n18
+}
+
+_sfc_main.render = _sfc_render
+const app = createVaporApp(_sfc_main, {})
+app.mount('#app')
+</script>

+ 249 - 0
playground/src/example-todomvc.vue

@@ -0,0 +1,249 @@
+<script>
+import {
+  defineComponent,
+  reactive,
+  computed,
+  watchEffect,
+  onMounted,
+  onUnmounted,
+  nextTick,
+} from 'vue/vapor'
+
+import 'todomvc-app-css/index.css'
+
+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'
+}
+
+export default defineComponent({
+  setup() {
+    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)
+    }
+
+    async function editTodo(todo) {
+      state.beforeEditCache = todo.title
+      state.editedTodo = todo
+      await nextTick()
+      document.getElementById(`todo-${todo.id}-input`).focus()
+    }
+
+    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)
+    }
+
+    return {
+      state,
+      addTodo,
+      removeTodo,
+      editTodo,
+      doneEdit,
+      cancelEdit,
+      removeCompleted,
+    }
+  },
+})
+</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"
+        :checked="state.allDone"
+        @change="state.allDone = $event.target.checked"
+      />
+      <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"
+              :checked="todo.completed"
+              @change="todo.completed = $event.target.checked"
+            />
+            <label @dblclick="editTodo(todo)">{{ todo.title }}</label>
+            <button class="destroy" @click="removeTodo(todo)"></button>
+          </div>
+          <input
+            :id="`todo-${todo.id}-input`"
+            class="edit"
+            type="text"
+            :value="todo.title"
+            @input="todo.title = $event.target.value"
+            @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>

+ 4 - 1
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',
+    ],
   },
 })