]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
test(vapor): vapor todomvc e2e test
authorEvan You <evan@vuejs.org>
Sat, 8 Feb 2025 13:33:40 +0000 (21:33 +0800)
committerEvan You <evan@vuejs.org>
Sat, 8 Feb 2025 13:33:40 +0000 (21:33 +0800)
.github/workflows/test.yml
packages-private/vapor-e2e-test/__tests__/e2eUtils.ts [deleted file]
packages-private/vapor-e2e-test/__tests__/todomvc.spec.ts
packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
packages-private/vapor-e2e-test/todomvc/App.vue [new file with mode: 0644]
packages-private/vapor-e2e-test/todomvc/index.html
packages-private/vapor-e2e-test/todomvc/main.ts

index 70dc82248134f1a28afc3bfcbb9c7106055efbbb..a520aa147e88996ad61b0f96b156b27d7d74713c 100644 (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:
diff --git a/packages-private/vapor-e2e-test/__tests__/e2eUtils.ts b/packages-private/vapor-e2e-test/__tests__/e2eUtils.ts
deleted file mode 100644 (file)
index 2ffebeb..0000000
+++ /dev/null
@@ -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,
-  }
-}
index 23f9e18e6d7a261d1ea9108b24bb887ce5476a0c..3de8392e5e26e0bc8bfcfc4e0b741283f9fc22a8 100644 (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,
+  )
+})
index 9a60a26ebc3d5406e597b10428d09c166154d35b..360f48085a14e7ae4f587d9a89ffeae216f582fc 100644 (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')
diff --git a/packages-private/vapor-e2e-test/todomvc/App.vue b/packages-private/vapor-e2e-test/todomvc/App.vue
new file mode 100644 (file)
index 0000000..e7f9796
--- /dev/null
@@ -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>
index 689a5a8edfdbc8146444c06026d72c7019deb872..79052a023ba0deb1b356ee825728a9f374d67940 100644 (file)
@@ -1 +1,2 @@
 <script type="module" src="./main.ts"></script>
+<div id="app"></div>
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..42497ab518d6f3e6e2ff21ea9753a5ece133f13c 100644 (file)
@@ -0,0 +1,5 @@
+import { createVaporApp } from 'vue'
+import App from './App.vue'
+import 'todomvc-app-css/index.css'
+
+createVaporApp(App).mount('#app')