commit db0603dcfde70a0d41a453bcac322c5ade912b63 Author: rsgltzyd Date: Tue Oct 15 22:17:14 2024 +0800 init diff --git a/.env b/.env new file mode 100644 index 0000000..759d7a8 --- /dev/null +++ b/.env @@ -0,0 +1,16 @@ +BASE_URL="https://hlk.meiguanjia.net/#/" +H5_BASE_URL="https://hlk.meiguanjia.net/h5/#/?env=0&mid=464&tv=qym&query=%257B%2522tab%2522%253A%2522MODULE_202311200022011%2522%257D" +ACCOUNT=15500005555 +PASSWORD=hlk123 +SMSCODE=1660 + +ZHB_ACCOUNT=18571458300 +ZHB_PASSWORD=1 +ZHB_ADMIN_URL="https://shengyibao.meiguanjia.net/shair/components/shair-web/base-system/index.html#" +ZHB_BASE_URL="https://shengyibao.meiguanjia.net/young/" +ZHB_H5_URL="http://shengyibao.meiguanjia.net/h5/index.html#tenantId=1578151&view=0" + +MGJ_ACCOUNT=美管加演示 +MGJ_PASSWORD=1 +# MGJ_BASE_URL="https://vip1.meiguanjia.net/shair/?v=mgj" +MGJ_BASE_URL="https://young.meiguanjia.net/young/" \ No newline at end of file diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..821b25e --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,30 @@ +name: Playwright Tests +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + schedule: + - cron: '30 9 * * *' # 每天凌晨 12 点执行一次 + workflow_dispatch: # 手动触发 +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 'v20.17' + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run tests repeat 3 + run: npm run test:repeat + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..640084a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +.auth/ +.images/ +*.traineddata +/tessdata/**/*.traineddata + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b9a0069 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,138 @@ +{ + "name": "playwright-demo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "playwright-demo", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "csv-parse": "^5.5.6" + }, + "devDependencies": { + "@faker-js/faker": "^9.0.3", + "@playwright/test": "^1.48.1", + "@types/node": "^22.7.5", + "dotenv": "^16.4.5" + } + }, + "node_modules/@faker-js/faker": { + "version": "9.0.3", + "resolved": "https://registry.npmmirror.com/@faker-js/faker/-/faker-9.0.3.tgz", + "integrity": "sha512-lWrrK4QNlFSU+13PL9jMbMKLJYXDFu3tQfayBsMXX7KL/GiQeqfB1CzHkqD5UHBUtPAuPo6XwGbMFNdVMZObRA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.48.1", + "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.48.1.tgz", + "integrity": "sha512-s9RtWoxkOLmRJdw3oFvhFbs9OJS0BzrLUc8Hf6l2UdCNd1rqeEyD4BhCJkvzeEoD1FsK4mirsWwGerhVmYKtZg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.48.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/csv-parse": { + "version": "5.5.6", + "resolved": "https://registry.npmmirror.com/csv-parse/-/csv-parse-5.5.6.tgz", + "integrity": "sha512-uNpm30m/AGSkLxxy7d9yRXpJQFrZzVWLFBkS+6ngPcZkw/5k3L/jjFuj7tVnEpRn+QgmiXr21nDlhCiUK4ij2A==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.48.1", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.48.1.tgz", + "integrity": "sha512-j8CiHW/V6HxmbntOfyB4+T/uk08tBy6ph0MpBXwuoofkSnLmlfdYNNkFTYD6ofzzlSqLA1fwH4vwvVFvJgLN0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.48.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.48.1", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.48.1.tgz", + "integrity": "sha512-Yw/t4VAFX/bBr1OzwCuOMZkY1Cnb4z/doAFSwf4huqAGWmf9eMNjmK7NiOljCdLmxeRYcGPPmcDgU0zOlzP0YA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f3ad260 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "playwright-demo", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "hlk_codegen": "npx playwright codegen https://hlk.meiguanjia.net/#/", + "mgj_codegen": "npx playwright codegen https://vip1.meiguanjia.net/shair/?v=mgj", + "zhb_codegen": "npx playwright codegen https://shengyibao.meiguanjia.net/young/", + "test:repeat": "npx playwright test ./tests/zhb ./tests/mgj ./tests/hlk --repeat-each=3", + "ui": "npx playwright test --ui", + "pwi": "npm ci && npx playwright install", + "pwu": "npx playwright install --with-deps" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@faker-js/faker": "^9.0.3", + "@playwright/test": "^1.48.1", + "@types/node": "^22.7.5", + "dotenv": "^16.4.5" + }, + "dependencies": { + "csv-parse": "^5.5.6" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..668190b --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,90 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); +const dotenv = require('dotenv'); +const path = require('path'); + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, '.env') }); +const hlkAuthFile = path.join(__dirname, '.auth/hlk_admin.json'); +const mgjAuthFile = path.join(__dirname, '.auth/mgj_admin.json'); + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config({ path: path.resolve(__dirname, '.env') }); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +module.exports = defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.BASE_URL, + timezoneId: 'Asia/Shanghai', + locale: 'zh-CN', + geolocation: { longitude: 114.24, latitude: 22.73 }, + permissions: ['geolocation'], + extraHTTPHeaders: { + 'Accept-Language': 'zh-CN,zh;q=0.9', + }, + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { name: 'hlk_setup', use: { baseURL: process.env.BASE_URL }, testMatch: /hlk\.setup\.js/ }, + { name: 'zhb_setup', use: { baseURL: process.env.ZHB_BASE_URL }, testMatch: /zhb\.setup\.js/ }, + { name: 'mgj_setup', use: { baseURL: process.env.MGJ_BASE_URL }, testMatch: /mgj\.setup\.js/ }, + { + name: 'chromium', + use: { + baseURL: process.env.BASE_URL, + ...devices['Desktop Chrome'], + storageState: hlkAuthFile, + }, + testMatch: '**/tests/hlk/**.spec.js', + dependencies: ['hlk_setup'], + }, + + { + name: 'chromium', + use: { + baseURL: process.env.ZHB_BASE_URL, + ...devices['Desktop Chrome'], + }, + testMatch: '**/tests/zhb/**.spec.js', + dependencies: ['zhb_setup'], + }, + { + name: 'chromium', + use: { + baseURL: process.env.MGJ_BASE_URL, + ...devices['Desktop Chrome'], + storageState: mgjAuthFile, + }, + testMatch: '**/tests/mgj/**.spec.js', + dependencies: ['mgj_setup'], + }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/tests-examples/demo-todo-app.spec.js b/tests-examples/demo-todo-app.spec.js new file mode 100644 index 0000000..e2eb87c --- /dev/null +++ b/tests-examples/demo-todo-app.spec.js @@ -0,0 +1,449 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +test.beforeEach(async ({ page }) => { + await page.goto('https://demo.playwright.dev/todomvc'); +}); + +const TODO_ITEMS = [ + 'buy some cheese', + 'feed the cat', + 'book a doctors appointment' +]; + +test.describe('New Todo', () => { + test('should allow me to add todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0] + ]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1] + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test('should clear text input field when an item is added', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test('should append new items to the bottom of the list', async ({ page }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(todoCount).toHaveText('3 items left'); + await expect(todoCount).toContainText('3'); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe('Mark all as completed', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should allow me to mark all items as completed', async ({ page }) => { + // Complete all todos. + await page.getByLabel('Mark all as complete').check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe('Item', () => { + + test('should allow me to mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + // Check first item. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.getByTestId('todo-item').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.getByRole('checkbox').check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).toHaveClass('completed'); + }); + + test('should allow me to un-mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const firstTodo = page.getByTestId('todo-item').nth(0); + const secondTodo = page.getByTestId('todo-item').nth(1); + const firstTodoCheckbox = firstTodo.getByRole('checkbox'); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test('should allow me to edit an item', async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId('todo-item'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]); + await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); +}); + +test.describe('Editing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should hide other controls when editing', async ({ page }) => { + const todoItem = page.getByTestId('todo-item').nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); + await expect(todoItem.locator('label', { + hasText: TODO_ITEMS[1], + })).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should trim entered text', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages '); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should remove the item if an empty text string was entered', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[2], + ]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('2'); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe('Clear completed button', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test('should display the correct text', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible(); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should be hidden when there are no items that are completed', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const todoItems = page.getByTestId('todo-item'); + const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + }); +}); + +test.describe('Routing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test('should allow me to display active items', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should respect the back button', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.getByRole('link', { name: 'Active' }).click(); + }); + + await test.step('Showing completed items', async () => { + await page.getByRole('link', { name: 'Completed' }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Completed' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(1); + }); + + test('should allow me to display all items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await page.getByRole('link', { name: 'Completed' }).click(); + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); + + //create locators for active and completed links + const activeLink = page.getByRole('link', { name: 'Active' }); + const completedLink = page.getByRole('link', { name: 'Completed' }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass('selected'); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page) { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } +} + +/** + * @param {import('@playwright/test').Page} page + * @param {number} expected + */ + async function checkNumberOfTodosInLocalStorage(page, expected) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +/** + * @param {import('@playwright/test').Page} page + * @param {number} expected + */ + async function checkNumberOfCompletedTodosInLocalStorage(page, expected) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).filter(i => i.completed).length === e; + }, expected); +} + +/** + * @param {import('@playwright/test').Page} page + * @param {string} title + */ +async function checkTodosInLocalStorage(page, title) { + return await page.waitForFunction(t => { + return JSON.parse(localStorage['react-todos']).map(i => i.title).includes(t); + }, title); +} diff --git a/tests/hlk/demo.spec.js b/tests/hlk/demo.spec.js new file mode 100644 index 0000000..d0e178d --- /dev/null +++ b/tests/hlk/demo.spec.js @@ -0,0 +1,60 @@ +const { test, expect } = require('./fixtures/common'); +const { Customer } = require('./pom/customer'); +const { faker } = require('@faker-js/faker/locale/zh_CN'); + +test('登录touch和h5,创建顾客购买会员卡,使用顾客账号到h5商城购买货品', async ({ + touchPage, + h5Page, + touchCustomerPage, + h5LoginPage, +}) => { + const phone = faker.helpers.fromRegExp(/1[3-9][0-1]{9}/); + const customer = new Customer(1, 1, { phone: phone }); + + await touchCustomerPage.createCustomer(customer); + /** @type string*/ + let cardName; + await test.step('在touch页面购买会员卡', async () => { + await touchPage.getByText('去开单').click(); + await touchPage.locator('.more > .icon > svg').click(); + await touchPage.getByText('去开卡').click(); + const $firstCard = touchPage.locator('.memberCard_box').first(); + await $firstCard.click(); + cardName = await $firstCard.locator('.card_name').innerText(); + await touchPage.getByRole('button', { name: '去结算' }).click(); + await touchPage.locator('.row').filter({ hasText: '总额' }).locator('.touchIcon').click(); + await touchPage.getByPlaceholder('请输入内容').fill('1000'); + await touchPage.locator('div').filter({ hasText: /^789$/ }).getByRole('button').nth(3).click(); + await touchPage.locator('.paymentInfoItem', { hasText: '现金' }).click(); + await touchPage.getByText('推送消费提醒').click(); + await touchPage.getByLabel('结算签字').uncheck(); + await touchPage.getByRole('button', { name: /结\s算/ }).click(); + await touchPage.getByRole('button', { name: /跳\s过/ }).click(); + }); + + await test.step('登录h5,并且商城页面使用会员卡进行购买', async () => { + await h5LoginPage.login(customer.phone); + await h5Page.locator('.singIn_content > .iconfont').click(); + const element = h5Page.locator('.coupon_content'); + const boundingBox = await element.boundingBox(); + + if (boundingBox) { + const x = boundingBox.x + boundingBox.width / 2; // 元素水平中心 + const y = boundingBox.y - 10; // 元素上侧空白区域 + + await h5Page.mouse.click(x, y); + } + await h5Page.locator('.bar_item', { hasText: '商城' }).click(); + await expect(h5Page.locator('.title', { hasText: '商城' })).toBeVisible(); + await h5Page.locator('.li span', { hasText: '全部' }).click(); + await h5Page.locator('.p-item').nth(2).click(); + await h5Page.getByText('立即购买').click(); + await h5Page.locator('uni-text').filter({ hasText: '佘山二店' }).click(); + await h5Page.getByText('确认').click(); + await h5Page.getByText('哎哟代理卡').click(); + await expect(h5Page.getByText('确认支付')).toBeEnabled(); + await h5Page.getByText('确认支付').click(); + await expect(h5Page.getByText('支付成功').first()).toBeVisible(); + await expect(h5Page.getByText('查看订单')).toBeVisible(); + }); +}); diff --git a/tests/hlk/fixtures/base.js b/tests/hlk/fixtures/base.js new file mode 100644 index 0000000..a0aae09 --- /dev/null +++ b/tests/hlk/fixtures/base.js @@ -0,0 +1,38 @@ +const { test: base, expect, devices } = require('@playwright/test'); +const authFile = '.auth/hlk_admin.json'; + +const test = base.extend({ + /** + * @type {import('@playwright/test').Page} + */ + touchPage: async ({ browser, baseURL }, use) => { + const context = await browser.newContext({ + storageState: authFile, + }); + const page = await context.newPage(); + await page.goto(baseURL); + + await use(page); + + await page.close(); + await context.close(); + }, + /** + * @type {import('@playwright/test').Page} + */ + h5Page: async ({ browser }, use) => { + const iPhone = devices['iPhone 11']; + const context = await browser.newContext({ + ...iPhone, + storageState: undefined, + }); + const page = await context.newPage(); + await page.goto(process.env.H5_BASE_URL); + await use(page); + }, +}); + +module.exports = { + test, + expect, +}; diff --git a/tests/hlk/fixtures/common.js b/tests/hlk/fixtures/common.js new file mode 100644 index 0000000..4a2bd60 --- /dev/null +++ b/tests/hlk/fixtures/common.js @@ -0,0 +1,7 @@ +const { mergeTests } = require('@playwright/test'); +const { test: touchTest } = require('./touchFixture.js'); +const { test: h5Test } = require('./h5Fixture.js'); + +export const test = mergeTests(touchTest, h5Test); + +export { expect } from '@playwright/test'; diff --git a/tests/hlk/fixtures/h5Fixture.js b/tests/hlk/fixtures/h5Fixture.js new file mode 100644 index 0000000..f378207 --- /dev/null +++ b/tests/hlk/fixtures/h5Fixture.js @@ -0,0 +1,14 @@ +const { test: base, expect } = require('./base'); +const { H5LoginPage } = require('../pom/h5LoginPage'); + +const test = base.extend({ + /** + * @type {H5LoginPage} + */ + h5LoginPage: async ({ h5Page }, use) => { + const h5LoginPage = new H5LoginPage(h5Page); + await use(h5LoginPage); + }, +}); + +module.exports = { test, expect }; diff --git a/tests/hlk/fixtures/touchFixture.js b/tests/hlk/fixtures/touchFixture.js new file mode 100644 index 0000000..fe1fa97 --- /dev/null +++ b/tests/hlk/fixtures/touchFixture.js @@ -0,0 +1,14 @@ +const { test: base, expect } = require('./base'); +const { CustomerPage } = require('../pom/customerPage'); + +const test = base.extend({ + /** + * @type {CustomerPage} + */ + touchCustomerPage: async ({ touchPage }, use) => { + const customerPage = new CustomerPage(touchPage); + await use(customerPage); + }, +}); + +module.exports = { test, expect }; diff --git a/tests/hlk/pom/customer.js b/tests/hlk/pom/customer.js new file mode 100644 index 0000000..8e7344d --- /dev/null +++ b/tests/hlk/pom/customer.js @@ -0,0 +1,48 @@ +import { faker } from '@faker-js/faker/locale/zh_CN'; + +export class Customer { + /** + * @param {1 | 2} store - 门店 + * @param {1 | 2} department - 部门 + * @param {string} [username=faker.person.fullName()] - 用户名 + * @param {string} [phone=faker.helpers.fromRegExp(/1[3-9][0-9]{6}/)] - 手机号 + * @param {string} [archive=""] - 档案号 + * @param {number} [gender=0] - 性别 + * @param {number} [source=1] - 顾客来源 + * @param {Object} [birthday=undefined] - 生日 + * @param {number} [birthday.year] - 年 + * @param {number} [birthday.month] - 月 + * @param {number} [birthday.day] - 月 + * @param {string} [remark=undefined] + * @param {Array} [employee=undefined] + * @constructor + * @example + * const customer = new Client(1, 2, { username: '22' }); + * const customer = new Customer(1, 2); + */ + constructor( + store, + department, + { + username = faker.person.fullName(), + phone = faker.helpers.fromRegExp(/1[3-9][0-9]{6}/), + archive = '', + gender = 0, + source = 1, + birthday = undefined, + remark = undefined, + employee = undefined, + } = {} + ) { + this.store = store; + this.department = department; + this.username = username; + this.phone = phone; + this.archive = archive; + this.gender = gender; + this.source = source; + this.birthday = birthday; + this.remark = remark; + this.employee = employee; + } +} diff --git a/tests/hlk/pom/customerPage.js b/tests/hlk/pom/customerPage.js new file mode 100644 index 0000000..7380e8f --- /dev/null +++ b/tests/hlk/pom/customerPage.js @@ -0,0 +1,326 @@ +//@ts-check +const { expect } = require('@playwright/test'); +const { Customer } = require('./customer'); + +class CustomerPage { + /** + * @param {import("@playwright/test").Page} page + */ + constructor(page) { + this.page = page; + this.$module = this.page.locator('.left_box .link_item').filter({ hasText: /顾客/ }); + this.$tabItem = this.page.locator('.top_tab .tab_item'); + this.$summary = this.$tabItem.filter({ hasText: '顾客概要' }); + this.$distribution = this.$tabItem.filter({ hasText: '顾客分配' }); + this.$dynamic = this.$tabItem.filter({ hasText: '顾客动态' }); + this.$analysis = this.$tabItem.filter({ hasText: '顾客分析' }); + this.$serviceLog = this.$tabItem.filter({ hasText: '服务日志' }); + this.$register = this.page.locator('.regmeber_warp', { hasText: '创建会员' }); + this.firstStore = { + firstDepartment: { no: 1, name: '美容部' }, + secondDepartment: { no: 2, name: '医美部' }, + }; + this.secondStore = { + firstDepartment: { no: 1, name: '美容部' }, + }; + this.source = [ + '邀客', + '员工带客', + '美团', + '大众点评', + '客带客', + '上门客人', + '百度糯米', + '支付宝', + ]; + } + + /** + * 跳转子页面,并且等待页面接口加载完成 + * @param {import("@playwright/test").Locator} locator + * @param {string[]} apiList + */ + gotoSubPage = async (locator, apiList) => { + await expect(async () => { + if (!(await locator.getAttribute('class'))?.includes('active')) { + await locator.click(); + } + await expect(locator).toHaveClass(/active/); + }).toPass({ timeout: 30000 }); + + await Promise.all( + apiList.map((api) => + this.page.waitForResponse((res) => res.url().includes(api) && res.status() === 200) + ) + ); + }; + + /** + * 跳转到顾客概要 + */ + gotoSummary = async () => { + await this.gotoSubPage(this.$summary, ['summary', 'todo']); + }; + + /** + * 跳转到顾客分配 + */ + gotoDistribution = async () => { + await this.gotoSubPage(this.$distribution, ['search_new', 'distribution']); + }; + + /** + * 跳转到顾客动态 + */ + gotoDynamic = async () => { + await this.gotoSubPage(this.$dynamic, ['daily_action']); + }; + + /** + * 跳转到顾客分析 + */ + gotoAnalysis = async () => { + await this.gotoSubPage(this.$analysis, ['analysis']); + }; + + /** + * 跳转到服务日志 + */ + gotoServiceLog = async () => { + await this.gotoSubPage(this.$serviceLog, ['service_log']); + }; + + /** + * 搜索顾客 + * @param {Customer} customer + */ + searchCustomer = async (customer) => { + const searchLocator = this.page.locator('.search_normal'); + const searchInput = this.page.getByPlaceholder('姓名(拼音首字)、手机号、档案号搜索'); + await searchInput.fill(customer.phone); + await searchLocator.filter({ has: searchInput }).getByText('搜索', { exact: true }).click(); + const customerLocator = this.page + .locator('.member_list .member_list_li') + .filter({ hasText: customer.username }); + await customerLocator.click(); + await expect(customerLocator).not.toBeVisible(); + }; + + /** + * 打开顾客详情页面 + * @param {Customer} customer + */ + openCustomerDetails = async (customer) => { + const $username = this.page.getByText(customer.username).last(); + await $username.click(); + const infoBox = this.page.locator('.member_info_box'); + + await Promise.all([ + expect(infoBox.getByText(customer.username)).toBeVisible(), + this.page.waitForFunction(() => { + return document.readyState === 'complete'; + }), + ]); + }; + + /** + * 关闭顾客详情页面 + */ + closeCustomerDetails = async () => { + const closeButton = this.page.locator('.member_info_box .close_icons > svg'); + await closeButton.click(); + await expect(closeButton).not.toBeVisible(); + }; + + /** + * 创建顾客 + * @param {Customer} customer 顾客 + * @returns {Promise} + */ + createCustomer = async (customer) => { + await expect(async () => { + await this.$module.click({ clickCount: 1 }); + await expect(this.$module).toHaveClass(/active/, { timeout: 2000 }); + }).toPass({ timeout: 30000 }); + + // 选择门店 + await this.page.locator('.search_store > div').click(); + await this.page.getByText('部门', { exact: true }).waitFor(); + await this.page.locator('.shopSelect_box .shopSelect_shop_content').click(); + await this.page + .locator('.com_picker .label') + .nth(customer.store - 1) + .click(); + await this.page.getByRole('button', { name: /保.*存/ }).click(); + + await this.page.getByRole('button', { name: '新增顾客' }).click(); + await this.$register.getByPlaceholder('请输入姓名').fill(customer.username); + await this.$register.getByPlaceholder('请输入会员手机号').fill(customer.phone); + await this.$register.getByPlaceholder('请输入会员手机号').click(); + + const checkPhoneLocator = this.$register + .locator('.ant-form-item', { hasText: '手机号' }) + .locator('.ant-form-explain'); + if (await checkPhoneLocator.isVisible()) { + const phoneStr = await checkPhoneLocator.innerText(); + if (phoneStr.includes('非法手机号码') || phoneStr.includes('请输入会员手机号')) { + throw new Error(`手机号码:${customer.phone}不正确`); + } + } + + // 输入档案号 + if (customer.archive) { + await this.$register.getByPlaceholder('请输入12位以内的数字或字母').fill(customer.archive); + } + + // 选择部门 + await this.$register.locator('#register_departmentNo').getByRole('combobox').click(); + if (customer.store === 1) { + if (customer.department === 1) { + await this.page.getByRole('option', { name: this.firstStore.firstDepartment.name }).click(); + } else if (customer.department === 2) { + await this.page + .getByRole('option', { name: this.firstStore.secondDepartment.name }) + .click(); + } + } else if (customer.store === 2) { + if (customer.department === 1) { + await this.page + .getByRole('option', { name: this.secondStore.firstDepartment.name }) + .click(); + } else { + throw new Error(`部门:${customer.department}不存在`); + } + } else { + throw new Error(`门店:${customer.store}不存在`); + } + + // 选择员工 + + // 选择性别 + if (customer.gender) { + if (customer.gender === 0) { + await this.$register.locator('label').filter({ hasText: '女性' }).click(); + } else if (customer.gender === 1) { + await this.$register.locator('label').filter({ hasText: '男性' }).click(); + } + } + + // 选择生日 + const birthday = customer.birthday; + const birthdayLocator = this.$register.locator('.ant-form-item', { hasText: '生日' }); + if (birthday) { + const { year, month, day } = birthday; + if (year) { + await birthdayLocator.getByText('年份').click(); + await this.page.getByRole('option', { name: `${year}` }).click(); + await expect(this.page.getByRole('option', { name: `${year}` })).not.toBeVisible(); + } + if (month && day) { + await birthdayLocator.getByText('日期').click(); + await this.page.getByRole('option', { name: new RegExp(`^${month}\\s月$`) }).click(); + await this.page.getByRole('option', { name: new RegExp(`^${day}\\s日$`) }).click(); + await this.page.getByRole('button', { name: /确\s认/ }).click(); + } else { + throw new Error(`month:${month}, day:${day}其中一个为空`); + } + } + + // 选择顾客来源 + // await this.$register.getByLabel(this.source[customer.source]).click(); + + await this.$register.getByText('请选择顾客来源').click(); + await this.page.getByRole('option', { name: this.source[customer.source] }).first().click(); + + // 选择备注 + if (customer.remark) { + await this.$register.getByPlaceholder('请输入1-100个字符备注内容').fill(customer.remark); + } + + const [response] = await Promise.all([ + this.page.waitForResponse( + async (res) => res.url().includes('/invalid_check') && (await res.json()).code === 'SUCCESS' + ), + this.page.getByRole('button', { name: '确认创建' }).click(), + ]); + const responseBody = await response.json(); + + const phoneStatus = responseBody?.content?.status; + if (phoneStatus) { + await this.page + .getByText('系统查询到当前手机号被建档后转为无效客,是否要恢复无效客?') + .waitFor(); + await this.page.getByRole('button', { name: '重新建档' }).click(); + // 检查弹窗信息 + const popupWindow = this.page.locator('.ant-message'); + await popupWindow.waitFor(); + const popupContent = (await popupWindow.innerText()).trim(); + if (popupContent.includes('该手机号码已经被使用')) { + throw new Error(`该手机号码:${customer.phone}已经被使用`); + } + } + + await this.page.locator('.person_content').waitFor(); + }; + + /** + * 设置顾客为无效客 + * @param {Customer} customer 顾客 + * @returns {Promise} + */ + setInvalidCustomer = async (customer) => { + await this.page.goto(process.env.BASE_URL || '', { waitUntil: 'load' }); + const moduleLocator = this.$module; + const activeLocator = moduleLocator.locator('.active_arrow'); + await expect(async () => { + if (!(await activeLocator.isVisible())) { + await moduleLocator.click({ clickCount: 1 }); + } + await expect(activeLocator).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 30_000 }); + + // 根据手机号进行搜索,进入顾客详情页面 + await this.page.getByPlaceholder('姓名(拼音首字)、手机号、档案号搜索').fill(customer.phone); + await this.page.locator('.ant-input-suffix .search_btn', { hasText: '搜索' }).click(); + await this.page.locator('.custom_content', { hasText: customer.phone }).click(); + await this.page.locator('.m-table__fixed-left').getByText(customer.username).first().click(); + + // 设置无效客 + await this.page.locator('.person_content').waitFor(); + await this.page.locator('.person_content .tag_box .more_icon svg').click(); + await this.page.getByRole('menuitem', { name: '设为无效客' }).click(); + const [response] = await Promise.all([ + this.page.waitForResponse( + (response) => + response.url().includes('/customer') && response.request().method() === 'PATCH' + ), + this.page.getByRole('button', { name: /确\s认/ }).click(), + ]); + + const responseBody = await response.json(); + const code = responseBody?.code; + expect(code).toBe('SUCCESS'); + }; + + /** + * 批量创建顾客 + * @param {Array} customerArray + */ + createMoreCustomer = async (customerArray) => { + for (const customer of customerArray) { + await this.createCustomer(customer); + } + }; + + /** + * 批量设置无效客 + * @param {Array} customerArray + */ + setMoreInvalidCustomer = async (customerArray) => { + for (const customer of customerArray) { + await this.setInvalidCustomer(customer); + } + }; +} + +module.exports = { CustomerPage }; diff --git a/tests/hlk/pom/h5LoginPage.js b/tests/hlk/pom/h5LoginPage.js new file mode 100644 index 0000000..f7d1483 --- /dev/null +++ b/tests/hlk/pom/h5LoginPage.js @@ -0,0 +1,19 @@ +class H5LoginPage { + constructor(page) { + this.page = page; + this.$account = this.page.locator('.input', { hasText: '请输入手机号码' }).locator('input'); + this.$SMSCode = this.page.locator('.input', { hasText: '请输入验证码' }).locator('input'); + this.$loginBtn = this.page.locator('uni-button').filter({ hasText: /^登录$/ }); + } + + login = async (account) => { + await this.page.locator('.login_btn').click(); + await this.$account.click(); + await this.$account.fill(account); + await this.$SMSCode.fill(process.env.SMSCODE); + await this.page.getByText('同意隐私协议').click(); + await this.$loginBtn.click(); + }; +} + +module.exports = { H5LoginPage }; diff --git a/tests/hlk/pom/homeNavigationPage.js b/tests/hlk/pom/homeNavigationPage.js new file mode 100644 index 0000000..361ecbc --- /dev/null +++ b/tests/hlk/pom/homeNavigationPage.js @@ -0,0 +1,58 @@ +import { expect } from '@playwright/test'; + +export class HomeNavigation { + /** + * @param {import("@playwright/test").Page} page + */ + constructor(page) { + this.page = page; + this.$moduleList = this.page.locator('.left_box .link_item'); + this.pages = { + cash: '收银', + appointment: '预约', + member: '顾客', + flow: '流水', + stock: '库存', + management: '目标', + marketing: '营销', + report: '报表', + setting: '设置', + other: '其他', + }; + } + + /** + * 跳转到对应模块 + * @param {string} pageName 模块名称 + * - cash-收银 + * - appointment-预约 + * - member-顾客 + * - flow-流水 + * - stock-库存 + * - management-目标 + * - marketing-营销 + * - report-报表 + * - setting-设置 + * - other-其他 + */ + gotoModule = async (pageName) => { + const moduleName = this.pages[pageName]; + + if (!moduleName) { + throw new Error(`Page ${pageName} does not exist`); + } + // 模块定位器 + const moduleLocator = this.$moduleList.filter({ hasText: moduleName }); + // 当前模块选中提示定位器 + const activeLocator = moduleLocator.locator('.active_arrow'); + + await expect(moduleLocator).toBeInViewport(); + + await expect(async () => { + if (!(await activeLocator.isVisible())) { + await moduleLocator.click({ clickCount: 1 }); + } + await expect(activeLocator).toBeInViewport(); + }).toPass({ timeout: 30_000 }); + }; +} diff --git a/tests/hlk/pom/touchCustomerPage.js b/tests/hlk/pom/touchCustomerPage.js new file mode 100644 index 0000000..e69de29 diff --git a/tests/hlk/pom/touchLoginPage.js b/tests/hlk/pom/touchLoginPage.js new file mode 100644 index 0000000..84672ba --- /dev/null +++ b/tests/hlk/pom/touchLoginPage.js @@ -0,0 +1,22 @@ +const { expect } = require('@playwright/test'); +class TouchLoginPage { + constructor(page) { + this.page = page; + this.$account = this.page.getByRole('textbox', { name: '请输入您的手机号码' }); + this.$password = this.page.getByRole('textbox', { name: '请输入登录密码' }); + this.$loginBtn = this.page.getByRole('button', { name: /登\s录/ }); + } + + login = async (account, password) => { + await this.$account.fill(account); + const phoneStatsIcon = this.page + .locator('.ant-row', { has: this.$account }) + .locator('.pass_svg'); + await this.$password.fill(password); + await this.page.getByLabel('请同意慧来客隐私政策和用户协议').check(); + await expect(phoneStatsIcon).toBeVisible(); + await this.$loginBtn.click(); + }; +} + +module.exports = { TouchLoginPage }; diff --git a/tests/hlk/setup/hlk.setup.js b/tests/hlk/setup/hlk.setup.js new file mode 100644 index 0000000..8a8de7c --- /dev/null +++ b/tests/hlk/setup/hlk.setup.js @@ -0,0 +1,22 @@ +const { test: setup, expect } = require('@playwright/test'); +const path = require('path'); +const hlkAuthFile = path.join(process.cwd(), '.auth', 'hlk_admin.json'); + +setup('hlk总部管理员登录', async ({ page, baseURL }) => { + const $account = page.getByRole('textbox', { name: '请输入您的手机号码' }); + const $password = page.getByRole('textbox', { name: '请输入登录密码' }); + const $loginBtn = page.getByRole('button', { name: /登\s录/ }); + + const account = process.env.ACCOUNT; + const password = process.env.PASSWORD; + + await page.goto(baseURL); + await $account.fill(account); + const phoneStatsIcon = page.locator('.ant-row', { has: $account }).locator('.pass_svg'); + await $password.fill(password); + await page.getByLabel('请同意慧来客隐私政策和用户协议').check(); + await expect(phoneStatsIcon).toBeVisible(); + await $loginBtn.click(); + await expect(page.getByRole('button', { name: /开\s单/ })).toBeVisible(); + await page.context().storageState({ path: hlkAuthFile }); +}); \ No newline at end of file diff --git a/tests/mgj/demo.spec.js b/tests/mgj/demo.spec.js new file mode 100644 index 0000000..c3761a7 --- /dev/null +++ b/tests/mgj/demo.spec.js @@ -0,0 +1,56 @@ +const { test, expect } = require('./fixture/common'); +const { Customer } = require('./pom/customerPage'); + +test('demo', async ({ mgjPage, customerPage }) => { + await test.step('创建顾客', async () => { + await expect(mgjPage.locator('#autoStationTip').getByText('我知道了')).toBeVisible(); + await mgjPage.locator('#autoStationTip').getByText('我知道了').click(); + await mgjPage.locator('#tab_main li').filter({ hasText: '顾客' }).click(); + const customer = new Customer(); + await customerPage.createCustomer(customer); + }); + + await test.step('开单结算,购买第一个项目', async () => { + await mgjPage.getByText('去开单').click(); + await mgjPage.locator('span').filter({ hasText: '确认开单' }).click(); + const firstEnabledElement = mgjPage + .locator('#createService .content ul .am-clickable:not(.am-disabled)') + .first(); + await mgjPage.waitForTimeout(1000); + await firstEnabledElement.click(); + + await expect( + mgjPage.locator('#cashierTotalPanel span').filter({ hasText: /结\s算/ }) + ).toBeVisible(); + + await mgjPage.waitForTimeout(1000); + + // 选择第一个项目 + await mgjPage.locator('.cashierItems .am-clickable').first().click(); + await expect(mgjPage.locator('.cashierBox tbody tr.am-clickable').first()).toBeVisible(); + await mgjPage + .locator('#cashierTotalPanel span') + .filter({ hasText: /结\s算/ }) + .click(); + + await expect(async () => { + const $signature = mgjPage.locator('#page_pay').getByText('结算签字'); + await $signature.click(); + await expect($signature).not.toHaveClass(/checked/, { timeout: 2000 }); + }).toPass(); + + await expect(async () => { + const $cashBtn = mgjPage.locator('#page_pay .pay_cash'); + await $cashBtn.click(); + await expect(mgjPage.locator('#page_pay .pay_cash.selected')).toBeVisible(); + await mgjPage + .locator('#page_pay span') + .filter({ hasText: /结\s算/ }) + .click(); + await expect(mgjPage.getByText('顾客满意度点评')).toBeVisible({ timeout: 2000 }); + }).toPass(); + + await mgjPage.getByText('不想评价').click(); + await expect(mgjPage.locator('#page_footBathPay').getByText('立即返回')).not.toBeVisible(); + }); +}); diff --git a/tests/mgj/fixture/base.js b/tests/mgj/fixture/base.js new file mode 100644 index 0000000..223e017 --- /dev/null +++ b/tests/mgj/fixture/base.js @@ -0,0 +1,25 @@ +const { test: base, expect } = require('@playwright/test'); +const authFile = '.auth/mgj_admin.json'; +const test = base.extend({ + /** + * @type {import('@playwright/test').Page} + */ + mgjPage: async ({ browser, baseURL }, use) => { + const context = await browser.newContext({ + storageState: authFile, + }); + const page = await context.newPage(); + await page.goto(baseURL); + console.log(baseURL); + + await use(page); + + await page.close(); + await context.close(); + }, +}); + +module.exports = { + test, + expect, +}; diff --git a/tests/mgj/fixture/common.js b/tests/mgj/fixture/common.js new file mode 100644 index 0000000..752350b --- /dev/null +++ b/tests/mgj/fixture/common.js @@ -0,0 +1,6 @@ +const { mergeTests } = require('@playwright/test'); +const { test: customerTest } = require('./customerFixture'); + +export const test = mergeTests(customerTest); + +export { expect } from '@playwright/test'; diff --git a/tests/mgj/fixture/customerFixture.js b/tests/mgj/fixture/customerFixture.js new file mode 100644 index 0000000..47dace3 --- /dev/null +++ b/tests/mgj/fixture/customerFixture.js @@ -0,0 +1,12 @@ +import { test as base } from './base'; +import { CustomerPage } from '../pom/customerPage'; + +export const test = base.extend({ + /** + * @type {CustomerPage} + */ + customerPage: async ({ mgjPage }, use) => { + const customerPage = new CustomerPage(mgjPage); + await use(customerPage); + }, +}); diff --git a/tests/mgj/fixture/h5Fixture.js b/tests/mgj/fixture/h5Fixture.js new file mode 100644 index 0000000..1e504ee --- /dev/null +++ b/tests/mgj/fixture/h5Fixture.js @@ -0,0 +1,14 @@ +const { test: base, expect } = require('./base'); +const { H5LoginPage } = require('../pom/hlk/h5LoginPage'); + +const test = base.extend({ + /** + * @type {H5LoginPage} + */ + h5LoginPage: async ({ h5Page }, use) => { + const h5LoginPage = new H5LoginPage(h5Page); + await use(h5LoginPage); + }, +}); + +module.exports = { test, expect }; diff --git a/tests/mgj/pom/customerPage.js b/tests/mgj/pom/customerPage.js new file mode 100644 index 0000000..c3dd013 --- /dev/null +++ b/tests/mgj/pom/customerPage.js @@ -0,0 +1,54 @@ +const { faker } = require('@faker-js/faker/locale/zh_CN'); +const { expect } = require('@playwright/test'); + +export class Customer { + constructor({ + name = faker.person.fullName(), + phone = faker.helpers.fromRegExp(/1[3-9][0-9]{6}/), + } = {}) { + this.name = name; + this.phone = phone; + } +} + +export class CustomerPage { + /** + * @param {import('@playwright/test').Page} page + */ + constructor(page) { + this.page = page; + } + + /** + * 创建顾客 + * @param {Customer} customer + */ + createCustomer = async (customer) => { + console.log(customer.name); + console.log(customer.phone); + + await expect(this.page.locator('#page_member').getByText('新增顾客档案')).toBeVisible(); + await this.page.locator('#page_member').getByText('新增顾客档案').click(); + + await this.page.getByPlaceholder('请输入会员姓名').fill(customer.name); + await this.page.getByPlaceholder('请输入手机号码').click(); + await expect(this.page.getByText('请输入数字')).toBeVisible(); + await this.page.getByPlaceholder('在此输入').click(); + await this.page.keyboard.type(customer.phone, { delay: 100 }); + // await this.page.getByPlaceholder('在此输入').type(customer.phone, { delay: 100 }); + await this.page.locator('#maskBoard').getByText('确认').click(); + await expect(this.page.getByPlaceholder('请输入手机号码')).toHaveValue(customer.phone, { + timeout: 2000, + }); + await this.page + .locator('div') + .filter({ hasText: /^员工带客$/ }) + .locator('span') + .first() + .click(); + await this.page.getByText('创建并选择').click(); + await expect(this.page.getByText('用户资料创建成功!')).toBeVisible(); + await this.page.getByText('以后再说').click(); + await expect(this.page.getByText('以后再说')).not.toBeVisible(); + }; +} diff --git a/tests/mgj/pom/h5LoginPage.js b/tests/mgj/pom/h5LoginPage.js new file mode 100644 index 0000000..f7d1483 --- /dev/null +++ b/tests/mgj/pom/h5LoginPage.js @@ -0,0 +1,19 @@ +class H5LoginPage { + constructor(page) { + this.page = page; + this.$account = this.page.locator('.input', { hasText: '请输入手机号码' }).locator('input'); + this.$SMSCode = this.page.locator('.input', { hasText: '请输入验证码' }).locator('input'); + this.$loginBtn = this.page.locator('uni-button').filter({ hasText: /^登录$/ }); + } + + login = async (account) => { + await this.page.locator('.login_btn').click(); + await this.$account.click(); + await this.$account.fill(account); + await this.$SMSCode.fill(process.env.SMSCODE); + await this.page.getByText('同意隐私协议').click(); + await this.$loginBtn.click(); + }; +} + +module.exports = { H5LoginPage }; diff --git a/tests/mgj/setup/mgj.setup.js b/tests/mgj/setup/mgj.setup.js new file mode 100644 index 0000000..36c89d9 --- /dev/null +++ b/tests/mgj/setup/mgj.setup.js @@ -0,0 +1,17 @@ +const { test: setup, expect } = require('@playwright/test'); +const path = require('path'); +const mgjAuthFile = path.join(process.cwd(), '.auth', 'mgj_admin.json'); + +setup('mgj管理员登录', async ({ page, baseURL }) => { + const account = process.env.MGJ_ACCOUNT; + const password = process.env.MGJ_PASSWORD; + + await page.goto(baseURL); + await page.getByText('点击此处帐号登录').click(); + await page.getByPlaceholder('用户名').fill(account); + await page.getByPlaceholder('请输入密码').fill(password); + await page.getByText('登录', { exact: true }).click(); + await page.locator('#shopSelect').getByText('一店').click(); + await expect(page.locator('#autoStationTip').getByText('我知道了')).toBeVisible(); + await page.context().storageState({ path: mgjAuthFile }); +}); diff --git a/tests/zhb/csv-demo.spec.js b/tests/zhb/csv-demo.spec.js new file mode 100644 index 0000000..1d17b73 --- /dev/null +++ b/tests/zhb/csv-demo.spec.js @@ -0,0 +1,118 @@ +const path = require('path'); +const { parse } = require('csv-parse/sync'); +const fs = require('fs'); +const { faker } = require('@faker-js/faker'); +const { test, expect } = require('./fixture/common'); +const { Customer } = require('./pom/customerPage'); + +const records = parse(fs.readFileSync(path.join(__dirname, 'zhb.csv')), { + columns: true, + skip_empty_lines: true, +}); + +test('csv', async ({ zhbPage }, workerInfo) => { + console.log(records.length); + const $area = zhbPage + .locator('.area') + .filter({ has: zhbPage.locator('.area-name', { hasText: '二楼' }) }); + const $$room = $area.locator('.room-list .room'); + + // 随机csv文件中的任意一个顾客 + const record = records[faker.number.int({ min: 0, max: records.length - 1 })]; + const { name, phone } = record; + console.log({ name, phone }); + const customer = new Customer({ name: name, phone: name }); + + // 使用房间名称 + let useRoomName; + await test.step('购买商品', async () => { + await zhbPage.locator('#tab_main li').filter({ hasText: '营业' }).click(); + const $emptyRoom = $$room + .filter({ has: zhbPage.getByText('空房') }) + .nth(workerInfo.workerIndex % 3); + useRoomName = await $emptyRoom.locator('.roomName').innerText(); + expect(useRoomName).not.toBeNull(); + await $emptyRoom.click(); + await expect(async () => { + if (await zhbPage.locator('.close > .iconfont').first().isVisible()) { + await zhbPage.locator('.close > .iconfont').first().click(); + } + await zhbPage.getByRole('button', { name: '选择顾客' }).click({ timeout: 3000 }); + await expect(zhbPage.locator('#page_searchMember').getByText('创建会员')).toBeVisible(); + }).toPass(); + await zhbPage + .getByRole('textbox', { name: '输入会员手机号或姓名或卡号搜索' }) + .fill(customer.phone, { delay: 100 }); + await zhbPage.locator('#page_searchMember svg').click(); + + const $customerTr = zhbPage + .locator('.list-warp') + .filter({ has: zhbPage.locator('.name', { hasText: customer.name }) }); + await $customerTr.locator('.list-body').first().click(); + + await zhbPage.getByText('项目开单').click(); + await expect(zhbPage.locator('#page_roomDetail').getByText('服务项目')).toBeVisible(); + await zhbPage.getByText('选择', { exact: true }).nth(1).click(); + await expect(zhbPage.locator('#serviceSelector').getByText('项目选择')).toBeVisible(); + await zhbPage + .locator('.goods-content-item') + .nth(faker.number.int({ min: 0, max: 14 })) + .click(); + await zhbPage.locator('#serviceSelector').getByText('确认').click(); + await zhbPage + .locator('div') + .filter({ hasText: /^明星足浴$/ }) + .locator('span') + .first() + .click(); + await zhbPage.getByRole('button', { name: '完成开单' }).click(); + await expect(zhbPage.getByRole('button', { name: '结账' })).toBeVisible({ timeout: 30_000 }); + await zhbPage.getByRole('button', { name: '结账' }).click(); + await zhbPage.locator('#page_footBathPay').getByText('结算签字').click(); + + await expect(async () => { + await zhbPage.locator('#page_footBathPay li').filter({ hasText: '现金' }).click(); + await expect(zhbPage.locator('#page_footBathPay li').filter({ hasText: '现金' })).toHaveClass( + /selected/ + ); + await zhbPage + .locator('#page_footBathPay span') + .filter({ hasText: /结\s算/ }) + .click(); + await expect(zhbPage.getByText('顾客满意度点评')).toBeVisible({ timeout: 2000 }); + }).toPass(); + + await zhbPage.getByText('不想评价').click(); + await zhbPage.locator('#page_footBathPay').getByText('立即返回').click(); + }); + + await test.step('起钟下钟,清理房间', async () => { + const $cleanRoom = $$room.filter({ + has: zhbPage.locator('.roomName', { hasText: new RegExp(`^${useRoomName}$`) }), + }); + await expect($cleanRoom).toContainText('已结清'); + await $cleanRoom.click(); + await zhbPage.getByText('技师操作').click(); + await zhbPage.getByText('起钟', { exact: true }).click(); + await zhbPage.getByText('起钟成功!').click(); + await zhbPage.getByText('技师操作').click(); + await zhbPage.getByText('下钟', { exact: true }).click(); + await zhbPage + .locator('div') + .filter({ hasText: /^确认返回$/ }) + .locator('div') + .first() + .click(); + await zhbPage.getByRole('button', { name: '不需要' }).click(); + + await expect($cleanRoom).toContainText('打扫'); + await $cleanRoom.click(); + await zhbPage + .locator('div') + .filter({ hasText: /^确定取消$/ }) + .locator('div') + .first() + .click(); + await expect($cleanRoom).toContainText('空房'); + }); +}); diff --git a/tests/zhb/fixture/base.js b/tests/zhb/fixture/base.js new file mode 100644 index 0000000..231b324 --- /dev/null +++ b/tests/zhb/fixture/base.js @@ -0,0 +1,67 @@ +const { test: base, expect, devices } = require('@playwright/test'); +const zhbAuthFile = '.auth/zhb.json'; +const zhbAdminAuthFile = '.auth/zhb_admin.json'; +const test = base.extend({ + /** + * @type {import('@playwright/test').Page} + */ + zhbPage: async ({ browser, baseURL }, use) => { + const context = await browser.newContext({ + storageState: zhbAuthFile, + }); + const page = await context.newPage(); + await page.goto(baseURL); + console.log(baseURL); + + await use(page); + + await page.close(); + await context.close(); + }, + /** + * @type {import('@playwright/test').Page} + */ + zhbAdminPage: async ({ browser }, use) => { + const baseURL = process.env.ZHB_ADMIN_URL; + const account = process.env.ZHB_ACCOUNT; + const password = process.env.ZHB_PASSWORD; + const context = await browser.newContext({ + storageState: zhbAdminAuthFile, + }); + const page = await context.newPage(); + await page.goto(baseURL); + console.log(baseURL); + + await page.getByPlaceholder('账户').fill(account); + await page.getByPlaceholder('密码').fill(password); + await page.getByRole('button', { name: '登 录' }).click(); + await expect(page.locator('a').filter({ hasText: /^收银$/ })).toBeVisible(); + + await use(page); + + await page.close(); + await context.close(); + }, + /** + * @type {import('@playwright/test').Page} + */ + h5Page: async ({ browser }, use) => { + const baseURL = process.env.ZHB_H5_URL; + const iPhone = devices['iPhone 14']; + const context = await browser.newContext({ + ...iPhone, + storageState: undefined, + hasTouch: true, + isMobile: true, // 设置为移动设备 + viewport: iPhone.viewport, // 使用 iPhone 的视口配置 + }); + const page = await context.newPage(); + await page.goto(baseURL); + await use(page); + }, +}); + +module.exports = { + test, + expect, +}; diff --git a/tests/zhb/fixture/common.js b/tests/zhb/fixture/common.js new file mode 100644 index 0000000..96b013a --- /dev/null +++ b/tests/zhb/fixture/common.js @@ -0,0 +1,7 @@ +const { mergeTests } = require('@playwright/test'); +const { test: baseTest } = require('./base'); +const { test: customerTest } = require('./customerFixture'); + +export const test = mergeTests(baseTest, customerTest); + +export { expect } from '@playwright/test'; diff --git a/tests/zhb/fixture/customerFixture.js b/tests/zhb/fixture/customerFixture.js new file mode 100644 index 0000000..40823ab --- /dev/null +++ b/tests/zhb/fixture/customerFixture.js @@ -0,0 +1,12 @@ +import { test as base } from './base'; +import { CustomerPage } from '../pom/customerPage'; + +export const test = base.extend({ + /** + * @type {CustomerPage} + */ + customerPage: async ({ zhbPage }, use) => { + const customerPage = new CustomerPage(zhbPage); + await use(customerPage); + }, +}); diff --git a/tests/zhb/pom/customerPage.js b/tests/zhb/pom/customerPage.js new file mode 100644 index 0000000..9e97552 --- /dev/null +++ b/tests/zhb/pom/customerPage.js @@ -0,0 +1,51 @@ +const { faker } = require('@faker-js/faker/locale/zh_CN'); +const { expect } = require('@playwright/test'); + +export class Customer { + constructor({ + name = faker.person.fullName(), + phone = faker.helpers.fromRegExp(/123[0-5]{8}/), + } = {}) { + this.name = name; + this.phone = phone; + } +} + +export class CustomerPage { + constructor(page) { + this.page = page; + } + + /** + * 创建顾客 + * @param {Customer} customer + */ + createCustomer = async (customer) => { + console.log(customer.name); + console.log(customer.phone); + + await expect(this.page.locator('#page_member').getByText('新增顾客档案')).toBeVisible(); + await this.page.locator('#page_member').getByText('新增顾客档案').click(); + + await this.page.getByPlaceholder('请输入会员姓名').fill(customer.name); + await this.page.getByPlaceholder('请输入手机号码').click(); + await expect(this.page.getByText('请输入数字')).toBeVisible(); + await this.page.getByPlaceholder('在此输入').click(); + await this.page.keyboard.type(customer.phone, { delay: 50 }); + // await this.page.getByPlaceholder('在此输入').type(customer.phone, { delay: 100 }); + await this.page.locator('#maskBoard').getByText('确认').click(); + await expect(this.page.getByPlaceholder('请输入手机号码')).toHaveValue(customer.phone, { + timeout: 2000, + }); + await this.page + .locator('div') + .filter({ hasText: /^员工带客$/ }) + .locator('span') + .first() + .click(); + await this.page.getByText('创建', { exact: true }).click(); + await expect(this.page.getByText('用户资料创建成功!')).toBeVisible(); + await this.page.getByText('以后再说').click(); + await expect(this.page.getByText('以后再说')).not.toBeVisible(); + }; +} diff --git a/tests/zhb/pom/h5LoginPage.js b/tests/zhb/pom/h5LoginPage.js new file mode 100644 index 0000000..d2de043 --- /dev/null +++ b/tests/zhb/pom/h5LoginPage.js @@ -0,0 +1,25 @@ +const { processAndRecognizeCaptcha } = require('../../utils/helper'); + +class H5LoginPage { + constructor(page) { + this.page = page; + this.$account = this.page.getByPlaceholder('此处请输入手机号'); + this.$GraphCode = this.page.getByRole('textbox', { name: '请输入图形验证码' }); + this.$SmsCode = this.page.getByRole('textbox', { name: '验证码', exact: true }); + this.$loginBtn = this.page.locator('uni-button').filter({ hasText: /^登录$/ }); + } + + sendSmsCode = async (account) => { + const imagePath = '.images/captcha.png'; + const outputImagePath = '.images/output-captcha.png'; + + await this.$account.fill(account); + const $graph = this.page.getByRole('img', { name: '图形验证码' }); + await $graph.waitFor(); + await $graph.screenshot({ path: imagePath }); + + return await processAndRecognizeCaptcha(imagePath, outputImagePath); + }; +} + +module.exports = { H5LoginPage }; diff --git a/tests/zhb/setup/zhb.setup.js b/tests/zhb/setup/zhb.setup.js new file mode 100644 index 0000000..30d11c0 --- /dev/null +++ b/tests/zhb/setup/zhb.setup.js @@ -0,0 +1,20 @@ +const { test: setup, expect } = require('@playwright/test'); +const path = require('path'); +const zhbAuthFile = path.join(process.cwd(), '.auth', 'zhb.json'); + +setup('zhb总部管理员登录', async ({ page, baseURL }) => { + const account = process.env.ZHB_ACCOUNT; + const password = process.env.ZHB_PASSWORD; + + await page.goto(baseURL); + await page.getByText('账号登录').click(); + await page.getByPlaceholder('用户名').fill(account); + await page.getByPlaceholder('密码', { exact: true }).fill(password); + await page.getByText('登录', { exact: true }).click(); + await expect(page.getByText('演示一店')).toBeVisible(); + await expect(page.getByText('演示二店')).toBeVisible(); + + await page.getByText('演示一店').click(); + await expect(page.locator('#tab_main li').filter({ hasText: '营业' })).toBeVisible(); + await page.context().storageState({ path: zhbAuthFile }); +}); diff --git a/tests/zhb/zhb.csv b/tests/zhb/zhb.csv new file mode 100644 index 0000000..7e3cd39 --- /dev/null +++ b/tests/zhb/zhb.csv @@ -0,0 +1,201 @@ +name,phone +郸志国,12334502225 +纵佳琪,12352301430 +马熙瑶,12350424001 +应国平,12314501244 +委强,12322301255 +吕苡沫,12314440133 +柴沐辰,12311131155 +史国香,12333211154 +易天娇,12351430503 +靖敬彪,12321535101 +贡秀珍,12352123355 +悉艺涵,12311403141 +丁国芳,12343515153 +喻芳,12311322132 +析晨阳,12344201554 +酒建国,12332133555 +窦智杰,12304133243 +玉子豪,12314500535 +渠建军,12314142045 +甲婷婷,12340453445 +巫娟,12320023214 +扈斌,12324211053 +位杰,12311524133 +斛沐阳,12311151435 +驹志国,12305534111 +盘美方,12322214351 +柯婷,12314322025 +马涛,12340204331 +东政君,12332111030 +梁玉兰,12355420144 +安丽,12302350005 +赫俊杰,12341541431 +尉迟海燕,12313530222 +礼国珍,12321141225 +行梓豪,12343224431 +拉国荣,12351411412 +练国栋,12312010324 +戎沐宸,12343432310 +翟宇,12344114011 +桂梓晨,12340344054 +斋志明,12305321102 +及万佳,12335454335 +前雪,12345534331 +伦英,12321322045 +柏立伟,12345251522 +招欣怡,12324401040 +首茗泽,12323330351 +计乙萍,12303514002 +线婷方,12324501502 +九秀珍,12344040353 +茂蒙,12312210221 +红婷方,12340215445 +扈倩,12354532031 +原立伟,12342524245 +祖丽芳,12322124425 +蓬雨欣,12340321430 +定瑜,12350252355 +望丽萍,12311313430 +铁家豪,12352502012 +闳雅鑫,12313242150 +单于燕,12314042132 +杜奕泽,12345103503 +尉丽芳,12323404415 +徭国平,12321014220 +羽志国,12311511234 +弥安琪,12322541510 +阮梓玥,12302200542 +呼波,12351535225 +玉呈轩,12304451524 +针熙瑶,12354523544 +帖国平,12305535553 +班天娇,12330411042 +藏秀珍,12304203153 +计国琴,12351531450 +鄢国珍,12343500300 +屈国兰,12313202405 +齐子欣,12355341152 +曹安琪,12355140413 +甫建华,12354544421 +苗丽芬,12311142041 +完倩,12324441204 +綦雯静,12304221302 +蔺志明,12322530542 +磨晨阳,12315334001 +友秀珍,12312034423 +历燕,12330435540 +飞文昊,12315150424 +萧鹏,12333512443 +用中海,12334505210 +竭智杰,12312530202 +次国琴,12331300513 +乙一诺,12304041145 +析志明,12340411012 +曲红,12320421131 +闫馨羽,12332235531 +卷语桐,12305350101 +旷丹,12351401021 +植玉梅,12340034300 +祖国栋,12310142153 +乙艺涵,12301532402 +海芳,12313345003 +枝敬彪,12324501125 +雍国秀,12340243412 +章佳紫林,12320333352 +充丽萍,12322423001 +百雅鑫,12333040513 +鹿治涛,12324533401 +慎文韬,12350423141 +谯梓涵,12301052044 +针振东,12353344501 +绍奕辰,12350005540 +仉文昊,12332320502 +元梓玥,12335424410 +祝浩宇,12311335301 +来斌,12300325534 +刁志明,12320244541 +首兰英,12311150011 +练安琪,12342301055 +唐雯静,12312505520 +郝天娇,12312520301 +庄丽芳,12353444410 +源依诺,12342341501 +诸丽芬,12312415203 +朱慧,12344511420 +绍国辉,12341315304 +肥志明,12304445132 +包文韬,12330305102 +钱倩,12350342140 +樊丽芳,12332020302 +钱雨欣,12304114004 +鱼静,12331411232 +速宇泽,12331502245 +叔沐宸,12335305514 +进国英,12314454234 +费慧,12304155020 +漆治文,12352421501 +示雪,12353154301 +劳颖,12343235112 +谈馥君,12343113130 +兆呈轩,12320135205 +萧文韬,12304320034 +漫军,12321552123 +施国珍,12330141235 +纳静怡,12341035053 +贰阳,12305451142 +呼义轩,12315402403 +植语桐,12335354425 +路国荣,12304330054 +支梓豪,12333434504 +蔚宇航,12321411124 +第宇航,12342053534 +校海燕,12301142130 +纳喇诗雨,12323221524 +浑呈轩,12351243530 +弓梓馨,12353030231 +常玉梅,12352425540 +冀丽萍,12343501540 +北民,12321312355 +栋勇,12321145441 +原桂兰,12352325455 +陈榕融,12315235050 +况伟,12353031413 +丁浩辰,12345310354 +郭斌,12352334430 +巴蒙,12344251134 +可艳,12310255512 +玄浩辰,12331503043 +沙呈轩,12302013431 +荤成,12344214445 +林熙瑶,12305423343 +葛梓睿,12314114055 +湛斌,12345150514 +由静,12300130254 +侯振东,12324015213 +贺单,12301145101 +所宇轩,12331420152 +颜颖,12300005420 +咎玉梅,12352454144 +占俊杰,12324402343 +觉罗秀珍,12311240034 +范姜丽,12330542141 +弘梓浩,12343343451 +通雨欣,12331513024 +丘梓诚,12344112043 +贵梓晨,12311414030 +狄悦,12332305454 +穆凤英,12335205455 +金智杰,12301542505 +蒙癸霖,12305252022 +诺兰英,12303005354 +帛娟,12304243114 +在国英,12355121223 +严芳,12355412233 +狄治文,12320503352 +律雨欣,12345523042 +偶敬彪,12332433431 +求佳琪,12300144202 +唐中海,12351305521 +第治涛,12300442432 +壬家明,12335343552 \ No newline at end of file