Browse Source

feat(testing): Added and integrated playwright

pull/3848/head
Muhammed Mustafa 2 years ago
parent
commit
6998c3e6c7
  1. 1
      packages/nc-gui/components/dlg/TableCreate.vue
  2. 1
      packages/nc-gui/components/smartsheet/Cell.vue
  3. 1
      packages/nc-gui/components/smartsheet/Grid.vue
  4. 2
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  5. 24
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  6. 4
      scripts/playwright/.gitignore
  7. 119
      scripts/playwright/package-lock.json
  8. 14
      scripts/playwright/package.json
  9. 19
      scripts/playwright/pages/Base.ts
  10. 70
      scripts/playwright/pages/Cell/SelectOptionCell.ts
  11. 20
      scripts/playwright/pages/Cell/index.ts
  12. 53
      scripts/playwright/pages/Column/SelectOptionColumn.ts
  13. 69
      scripts/playwright/pages/Column/index.ts
  14. 36
      scripts/playwright/pages/Dashboard.ts
  15. 46
      scripts/playwright/pages/Grid.ts
  16. 107
      scripts/playwright/playwright.config.ts
  17. 29
      scripts/playwright/setup/index.ts
  18. 4
      scripts/playwright/storageState.json
  19. 398
      scripts/playwright/tests-examples/demo-todo-app.spec.ts
  20. 65
      scripts/playwright/tests/multiSelect.spec.ts
  21. 61
      scripts/playwright/tests/singleSelect.spec.ts

1
packages/nc-gui/components/dlg/TableCreate.vue

@ -105,6 +105,7 @@ onMounted(() => {
v-model:value="table.title"
size="large"
hide-details
data-pw="create-table-title-input"
:placeholder="$t('msg.info.enterTableName')"
/>
</a-form-item>

1
packages/nc-gui/components/smartsheet/Cell.vue

@ -126,6 +126,7 @@ const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
<div
class="nc-cell w-full"
:class="[`nc-cell-${(column?.uidt || 'default').toLowerCase()}`, { 'text-blue-600': isPrimary && !virtual && !isForm }]"
:data-pw="`cell-${column?.title}-${rowIndex}`"
@keydown.enter.exact="syncAndNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="syncAndNavigate(NavigateDir.PREV, $event)"
>

1
packages/nc-gui/components/smartsheet/Grid.vue

@ -607,6 +607,7 @@ watch(
:data-key="rowIndex + columnObj.id"
:data-col="columnObj.id"
:data-title="columnObj.title"
:data-pw="`cell-${columnObj.title}-${rowIndex}`"
@click="selectCell(rowIndex, colIndex)"
@dblclick="makeEditable(row, columnObj)"
@mousedown="startSelectRange($event, rowIndex, colIndex)"

2
packages/nc-gui/components/smartsheet/column/EditOrAdd.vue

@ -124,7 +124,7 @@ onMounted(() => {
:class="{ '!w-[600px]': formState.uidt === UITypes.Formula }"
@click.stop
>
<a-form v-model="formState" no-style name="column-create-or-edit" layout="vertical">
<a-form v-model="formState" no-style name="column-create-or-edit" layout="vertical" data-pw="add-or-edit-column">
<div class="flex flex-col gap-2">
<a-form-item :label="$t('labels.columnName')" v-bind="validateInfos.title">
<a-input

24
packages/nc-gui/components/smartsheet/column/SelectOptions.vue

@ -133,7 +133,12 @@ watch(inputs, () => {
<Draggable :list="options" item-key="id" handle=".nc-child-draggable-icon">
<template #item="{ element, index }">
<div class="flex py-1 items-center nc-select-option">
<MdiDragVertical v-if="!isKanban" small class="nc-child-draggable-icon handle" />
<MdiDragVertical
v-if="!isKanban"
small
class="nc-child-draggable-icon handle"
:data-pw="`select-option-column-handle-icon-${element.title}`"
/>
<a-dropdown
v-model:visible="colorMenus[index]"
:trigger="['click']"
@ -153,9 +158,20 @@ watch(inputs, () => {
/>
</a-dropdown>
<a-input ref="inputs" v-model:value="element.title" class="caption" @change="optionChanged(element.id)" />
<MdiClose class="ml-2 hover:!text-black" :style="{ color: 'red' }" @click="removeOption(index)" />
<a-input
ref="inputs"
v-model:value="element.title"
class="caption"
:data-pw="`select-column-option-input-${index}`"
@change="optionChanged(element.id)"
/>
<MdiClose
class="ml-2 hover:!text-black"
:style="{ color: 'red' }"
:data-pw="`select-column-option-remove-${index}`"
@click="removeOption(index)"
/>
</div>
</template>
<template #footer>

4
scripts/playwright/.gitignore vendored

@ -0,0 +1,4 @@
node_modules/
/test-results/
/playwright-report/
/playwright/.cache/

119
scripts/playwright/package-lock.json generated

@ -0,0 +1,119 @@
{
"name": "playwright",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "playwright",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.26.1",
"axios": "^0.24.0"
}
},
"node_modules/@playwright/test": {
"version": "1.26.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.26.1.tgz",
"integrity": "sha512-bNxyZASVt2adSZ9gbD7NCydzcb5JaI0OR9hc7s+nmPeH604gwp0zp17NNpwXY4c8nvuBGQQ9oGDx72LE+cUWvw==",
"dev": true,
"dependencies": {
"@types/node": "*",
"playwright-core": "1.26.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@types/node": {
"version": "18.8.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.1.tgz",
"integrity": "sha512-vuYaNuEIbOYLTLUAJh50ezEbvxrD43iby+lpUA2aa148Nh5kX/AVO/9m1Ahmbux2iU5uxJTNF9g2Y+31uml7RQ==",
"dev": true
},
"node_modules/axios": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
"dev": true,
"dependencies": {
"follow-redirects": "^1.14.4"
}
},
"node_modules/follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/playwright-core": {
"version": "1.26.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.26.1.tgz",
"integrity": "sha512-hzFchhhxnEiPc4qVPs9q2ZR+5eKNifY2hQDHtg1HnTTUuphYCBP8ZRb2si+B1TR7BHirgXaPi48LIye5SgrLAA==",
"dev": true,
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=14"
}
}
},
"dependencies": {
"@playwright/test": {
"version": "1.26.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.26.1.tgz",
"integrity": "sha512-bNxyZASVt2adSZ9gbD7NCydzcb5JaI0OR9hc7s+nmPeH604gwp0zp17NNpwXY4c8nvuBGQQ9oGDx72LE+cUWvw==",
"dev": true,
"requires": {
"@types/node": "*",
"playwright-core": "1.26.1"
}
},
"@types/node": {
"version": "18.8.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.1.tgz",
"integrity": "sha512-vuYaNuEIbOYLTLUAJh50ezEbvxrD43iby+lpUA2aa148Nh5kX/AVO/9m1Ahmbux2iU5uxJTNF9g2Y+31uml7RQ==",
"dev": true
},
"axios": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
"dev": true,
"requires": {
"follow-redirects": "^1.14.4"
}
},
"follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"dev": true
},
"playwright-core": {
"version": "1.26.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.26.1.tgz",
"integrity": "sha512-hzFchhhxnEiPc4qVPs9q2ZR+5eKNifY2hQDHtg1HnTTUuphYCBP8ZRb2si+B1TR7BHirgXaPi48LIye5SgrLAA==",
"dev": true
}
}
}

14
scripts/playwright/package.json

@ -0,0 +1,14 @@
{
"name": "playwright",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.26.1",
"axios": "^0.24.0"
}
}

19
scripts/playwright/pages/Base.ts

@ -0,0 +1,19 @@
// playwright-dev-page.ts
import { Page, expect } from '@playwright/test';
export class BasePage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async toastWait({message}: {message: string}) {
const toast = await this.page.locator('.ant-message .ant-message-notice-content', {hasText: message}).last();
await toast.waitFor({state: 'visible'});
// todo: text of toastr shows old one in the test assertion
await toast.last().textContent()
.then((text) => expect(text).toContain(message));
}
}

70
scripts/playwright/pages/Cell/SelectOptionCell.ts

@ -0,0 +1,70 @@
import { expect } from "@playwright/test";
import { CellPageObject } from ".";
export class SelectOptionCellPageObject {
readonly cell: CellPageObject;
constructor(cell: CellPageObject) {
this.cell = cell;
}
async select({index, columnHeader, option, multiSelect}: {index: number, columnHeader: string, option: string, multiSelect?: boolean}) {
await this.cell.get({index, columnHeader}).click();
const count = await this.cell.page.locator('.rc-virtual-list-holder .ant-select-item-option-content', {hasText: option}).count();
for(let i = 0; i < count; i++) {
if(await this.cell.page.locator('.rc-virtual-list-holder .ant-select-item-option-content', {hasText: option}).nth(i).isVisible()) {
await this.cell.page.locator('.rc-virtual-list-holder .ant-select-item-option-content', {hasText: option}).nth(i).click();
}
}
if(multiSelect) await this.cell.get({index, columnHeader}).click();
await this.cell.page.locator(`.nc-dropdown-single-select-cell`).nth(index).waitFor({state: 'hidden'});
}
async clear({index, columnHeader, multiSelect}: {index: number, columnHeader: string, multiSelect?: boolean}) {
if(multiSelect){
await this.cell.get({index, columnHeader}).click();
await this.cell.get({index, columnHeader}).click();
const optionCount = await this.cell.get({index, columnHeader}).locator('.ant-tag').count();
for(let i = 0; i < optionCount; i++) {
await this.cell.get({index, columnHeader}).locator('.ant-tag > .ant-tag-close-icon').first().click();
await this.cell.page.waitForTimeout(200);
}
return
}
await this.cell.get({index, columnHeader}).click();
await this.cell.page.locator('.ant-select-single > .ant-select-clear').click();
await this.cell.get({index, columnHeader}).click();
await this.cell.page.locator(`.nc-dropdown-single-select-cell`).waitFor({state: 'hidden'});
}
async verify({index, columnHeader, option, multiSelect}: {index: number, columnHeader: string, option: string, multiSelect?: boolean}) {
if(multiSelect) {
return expect(
this.cell.get({index, columnHeader})).toContainText(option, {useInnerText: true});
}
return expect(this.cell.get({index, columnHeader}).locator('.ant-select-selection-item > .ant-tag')).toHaveText(option, {useInnerText: true});
}
async verifyNoOptionsSelected({index, columnHeader}: {index: number, columnHeader: string}) {
return expect(this.cell.get({index, columnHeader}).locator('.ant-select-selection-item > .ant-tag')).toBeHidden();
}
async verifyOptions({index, columnHeader, options}: {index: number, columnHeader: string, options: string[]}) {
await this.cell.get({index, columnHeader}).click();
let counter = 0;
for (const option of options) {
const optionInDom = await this.cell.page.locator(`div.ant-select-item-option`).nth(counter)
.evaluate((node) => (node as HTMLElement).innerText)
expect(optionInDom).toBe(option);
counter++;
}
await this.cell.click({index, columnHeader});
await this.cell.page.locator(`.nc-dropdown-single-select-cell`).nth(index).waitFor({state: 'hidden'});
}
}

20
scripts/playwright/pages/Cell/index.ts

@ -0,0 +1,20 @@
import { Page, Locator } from "@playwright/test";
import { SelectOptionCellPageObject } from "./SelectOptionCell";
export class CellPageObject {
readonly page: Page;
readonly selectOption: SelectOptionCellPageObject;
constructor(page: Page) {
this.page = page;
this.selectOption = new SelectOptionCellPageObject(this);
}
get({index, columnHeader}: {index: number, columnHeader: string}): Locator {
return this.page.locator(`tr.nc-grid-row:nth-child(${index + 1}) > [data-title="${columnHeader}"]`);
}
async click({index, columnHeader}: {index: number, columnHeader: string}) {
return this.get({index, columnHeader}).click();
}
}

53
scripts/playwright/pages/Column/SelectOptionColumn.ts

@ -0,0 +1,53 @@
import { Page } from "@playwright/test";
import { ColumnPageObject } from ".";
export class SelectOptionColumnPageObject {
readonly column: ColumnPageObject;
constructor(column: ColumnPageObject) {
this.column = column;
}
async addOption({index, columnTitle,option, skipColumnModal}: {index: number, option: string, skipColumnModal?: boolean, columnTitle?: string}) {
if(!skipColumnModal && columnTitle) await this.column.openEdit({title: columnTitle});
await this.column.page.locator('button:has-text("Add option")').click();
// Fill text=Select options can't be nullAdd option >> input[type="text"]
await this.column.page.locator(`input[data-pw="select-column-option-input-${index}"]`).click();
await this.column.page.locator(`input[data-pw="select-column-option-input-${index}"]`).fill(option);
if(!skipColumnModal && columnTitle) await this.column.save({isUpdated: true});
}
async editOption({columnTitle, index, newOption}: {index: number, columnTitle: string, newOption: string}) {
await this.column.openEdit({title: columnTitle});
await this.column.page.locator(`input[data-pw="select-column-option-input-${index}"]`).click();
await this.column.page.locator(`input[data-pw="select-column-option-input-${index}"]`).fill(newOption);
await this.column.save({isUpdated: true});
}
async deleteOption({columnTitle, index}: {index: number, columnTitle: string}) {
await this.column.openEdit({title: columnTitle});
await this.column.page.locator(`svg[data-pw="select-column-option-remove-${index}"]`).click();
await this.column.save({isUpdated: true});
}
async reorderOption({columnTitle, sourceOption, destinationOption}: {columnTitle: string, sourceOption: string, destinationOption: string}) {
await this.column.openEdit({title: columnTitle});
await this.column.page.waitForTimeout(150);
await this.column.page.dragAndDrop(`svg[data-pw="select-option-column-handle-icon-${sourceOption}"]`, `svg[data-pw="select-option-column-handle-icon-${destinationOption}"]`, {
force: true,
});
await this.column.page.waitForTimeout(150);
await this.column.save({isUpdated: true});
}
}

69
scripts/playwright/pages/Column/index.ts

@ -0,0 +1,69 @@
import { Page, expect } from "@playwright/test";
import { BasePage } from "../Base";
import {SelectOptionColumnPageObject} from "./SelectOptionColumn";
export class ColumnPageObject {
readonly page: Page;
readonly basePage: BasePage;
readonly selectOption: SelectOptionColumnPageObject;
constructor(page: Page) {
this.page = page;
this.selectOption = new SelectOptionColumnPageObject(this);
this.basePage = new BasePage(this.page);
}
async create({title, type}: {title: string, type: string}) {
await this.page.locator('.nc-column-add').click();
await this.page.locator('form[data-pw="add-or-edit-column"]').waitFor();
// Click span:has-text("SingleLineText") >> nth=1
await this.page.locator('span:has-text("SingleLineText")').click();
// Fill text=Column Type SingleLineText >> input[role="combobox"]
await this.page.locator('text=Column Type SingleLineText >> input[role="combobox"]').fill(type);
// Select column type
await this.page.locator(`text=${type}`).nth(1).click();
switch (type) {
case 'SingleSelect':
case 'MultiSelect':
await this.selectOption.addOption({index: 0, option: 'Option 1', skipColumnModal: true});
await this.selectOption.addOption({index: 1, option: 'Option 2', skipColumnModal: true});
break;
default:
break;
}
await this.page.locator('.nc-column-name-input').fill(title);
await this.save();
}
async delete({title}: {title: string}) {
await this.page.locator(`text=#Title${title} >> svg >> nth=3`).click();
await this.page.locator('li[role="menuitem"]:has-text("Delete")').waitFor()
await this.page.locator('li[role="menuitem"]:has-text("Delete")').click();
await this.page.locator('button:has-text("Delete")').click();
// wait till modal is closed
await this.page.locator('.nc-modal-column-delete').waitFor({state: 'hidden'});
}
async openEdit({title}: {title: string}) {
await this.page.locator(`text=#Title${title} >> svg >> nth=3`).click();
await this.page.locator('li[role="menuitem"]:has-text("Edit")').waitFor()
await this.page.locator('li[role="menuitem"]:has-text("Edit")').click();
await this.page.locator('form[data-pw="add-or-edit-column"]').waitFor();
}
async save({isUpdated}: {isUpdated?: boolean} = {}) {
await this.page.locator('button:has-text("Save")').click();
await this.basePage.toastWait({message: isUpdated ? 'Column updated' : 'Column created'});
}
}

36
scripts/playwright/pages/Dashboard.ts

@ -0,0 +1,36 @@
// playwright-dev-page.ts
import { expect, Locator, Page } from '@playwright/test';
export class DashboardPage {
readonly project: any;
readonly page: Page;
readonly tablesSideBar: Locator;
readonly tabBar: Locator;
constructor(page: Page, project: any) {
this.page = page;
this.project = project;
this.tablesSideBar = page.locator('.nc-treeview-container');
this.tabBar = page.locator('.nc-tab-bar');
}
async goto() {
await this.page.goto(`http://localhost:3000/#/nc/${this.project.id}/auth`);
}
async openTable({ title }: { title: string }) {
await this.tablesSideBar.locator(`.nc-project-tree-tbl-${title}`).click();
await this.tabBar.textContent().then((text) => expect(text).toContain(title));
}
async createTable({ title }: { title: string }) {
await this.tablesSideBar.locator('.nc-add-new-table').click();
await this.page.locator('.ant-modal-body').waitFor()
await this.page.locator('[placeholder="Enter table name"]').fill(title);
await this.page.locator('button:has-text("Submit")').click();
await expect(this.page).toHaveURL(`http://localhost:3000/#/nc/${this.project.id}/table/${title}`);
}
}

46
scripts/playwright/pages/Grid.ts

@ -0,0 +1,46 @@
// playwright-dev-page.ts
import { Locator, Page, expect } from '@playwright/test';
import { CellPageObject } from './Cell';
import { ColumnPageObject } from './Column';
export class GridPage {
readonly page: Page;
readonly addNewTableButton: Locator;
readonly column: ColumnPageObject;
readonly cell: CellPageObject;
constructor(page: Page) {
this.page = page;
this.addNewTableButton = page.locator('.nc-add-new-table');
this.column = new ColumnPageObject(page);
this.cell = new CellPageObject(page);
}
async addNewRow({index = 0, title}: {index?: number, title?: string} = {}) {
await this.page.locator('.nc-grid-add-new-cell').click();
// Double click td >> nth=1
await this.page.locator('td[data-title="Title"]').nth(index).dblclick();
// Fill text=1Add new row >> input >> nth=1
await this.page.locator(`div[data-pw="cell-Title-${index}"] >> input`).fill(title ?? `Row ${index}`);
await this.page.locator('span[title="Title"]').click();
await this.page.locator('.nc-grid-wrapper').click();
}
async verifyRowDoesNotExist({index}: {index: number}) {
return expect(await this.page.locator(`td[data-pw="cell-Title-${index}"]`)).toBeHidden();
}
async deleteRow(index: number) {
await this.page.locator(`td[data-pw="cell-Title-${index}"]`).click({
button: 'right'
});
// Click text=Delete Row
await this.page.locator('text=Delete Row').click();
}
}

107
scripts/playwright/playwright.config.ts

@ -0,0 +1,107 @@
import type { PlaywrightTestConfig } from '@playwright/test';
import { devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
testDir: './tests',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000
},
/* 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 : undefined,
/* 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: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* 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: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// port: 3000,
// },
};
export default config;

29
scripts/playwright/setup/index.ts

@ -0,0 +1,29 @@
import { Page } from '@playwright/test';
import axios from 'axios';
import { DashboardPage } from '../pages/Dashboard';
const setup = async ({page}: {page: Page}) => {
const response = await axios.get('http://localhost:8080/api/v1/meta/test/reset');
const token = response.data.token;
await page.addInitScript(async ({token}) => {
try {
window.localStorage.setItem('nocodb-gui-v2', JSON.stringify({
token: token,
}));
} catch (e) {
window.console.log(e);
}
}, { token: token });
const project = response.data.projects.find((project) => project.title === 'externalREST');
await page.goto(`/#/nc/${project.id}/auth`);
const dashboardPage = new DashboardPage(page, project);
await dashboardPage.openTable({title: "Country"})
return { project, token };
}
export default setup;

4
scripts/playwright/storageState.json

@ -0,0 +1,4 @@
{
"cookies": [],
"origins": []
}

398
scripts/playwright/tests-examples/demo-todo-app.spec.ts

@ -0,0 +1,398 @@
import { test, expect, type Page } from '@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 1st todo.
await page.locator('.new-todo').fill(TODO_ITEMS[0]);
await page.locator('.new-todo').press('Enter');
// Make sure the list only has one todo item.
await expect(page.locator('.view label')).toHaveText([
TODO_ITEMS[0]
]);
// Create 2nd todo.
await page.locator('.new-todo').fill(TODO_ITEMS[1]);
await page.locator('.new-todo').press('Enter');
// Make sure the list now has two todo items.
await expect(page.locator('.view label')).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 one todo item.
await page.locator('.new-todo').fill(TODO_ITEMS[0]);
await page.locator('.new-todo').press('Enter');
// Check that input is empty.
await expect(page.locator('.new-todo')).toBeEmpty();
await checkNumberOfTodosInLocalStorage(page, 1);
});
test('should append new items to the bottom of the list', async ({ page }) => {
// Create 3 items.
await createDefaultTodos(page);
// Check test using different methods.
await expect(page.locator('.todo-count')).toHaveText('3 items left');
await expect(page.locator('.todo-count')).toContainText('3');
await expect(page.locator('.todo-count')).toHaveText(/3/);
// Check all items in one call.
await expect(page.locator('.view label')).toHaveText(TODO_ITEMS);
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should show #main and #footer when items added', async ({ page }) => {
await page.locator('.new-todo').fill(TODO_ITEMS[0]);
await page.locator('.new-todo').press('Enter');
await expect(page.locator('.main')).toBeVisible();
await expect(page.locator('.footer')).toBeVisible();
await checkNumberOfTodosInLocalStorage(page, 1);
});
});
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.locator('.toggle-all').check();
// Ensure all todos have 'completed' class.
await expect(page.locator('.todo-list li')).toHaveClass(['completed', 'completed', 'completed']);
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
});
test('should allow me to clear the complete state of all items', async ({ page }) => {
// Check and then immediately uncheck.
await page.locator('.toggle-all').check();
await page.locator('.toggle-all').uncheck();
// Should be no completed classes.
await expect(page.locator('.todo-list li')).toHaveClass(['', '', '']);
});
test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => {
const toggleAll = page.locator('.toggle-all');
await toggleAll.check();
await expect(toggleAll).toBeChecked();
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
// Uncheck first todo.
const firstTodo = page.locator('.todo-list li').nth(0);
await firstTodo.locator('.toggle').uncheck();
// Reuse toggleAll locator and make sure its not checked.
await expect(toggleAll).not.toBeChecked();
await firstTodo.locator('.toggle').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 two items.
for (const item of TODO_ITEMS.slice(0, 2)) {
await page.locator('.new-todo').fill(item);
await page.locator('.new-todo').press('Enter');
}
// Check first item.
const firstTodo = page.locator('.todo-list li').nth(0);
await firstTodo.locator('.toggle').check();
await expect(firstTodo).toHaveClass('completed');
// Check second item.
const secondTodo = page.locator('.todo-list li').nth(1);
await expect(secondTodo).not.toHaveClass('completed');
await secondTodo.locator('.toggle').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 two items.
for (const item of TODO_ITEMS.slice(0, 2)) {
await page.locator('.new-todo').fill(item);
await page.locator('.new-todo').press('Enter');
}
const firstTodo = page.locator('.todo-list li').nth(0);
const secondTodo = page.locator('.todo-list li').nth(1);
await firstTodo.locator('.toggle').check();
await expect(firstTodo).toHaveClass('completed');
await expect(secondTodo).not.toHaveClass('completed');
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await firstTodo.locator('.toggle').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.locator('.todo-list li');
const secondTodo = todoItems.nth(1);
await secondTodo.dblclick();
await expect(secondTodo.locator('.edit')).toHaveValue(TODO_ITEMS[1]);
await secondTodo.locator('.edit').fill('buy some sausages');
await secondTodo.locator('.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.locator('.todo-list li').nth(1);
await todoItem.dblclick();
await expect(todoItem.locator('.toggle')).not.toBeVisible();
await expect(todoItem.locator('label')).not.toBeVisible();
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should save edits on blur', async ({ page }) => {
const todoItems = page.locator('.todo-list li');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).locator('.edit').fill('buy some sausages');
await todoItems.nth(1).locator('.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.locator('.todo-list li');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).locator('.edit').fill(' buy some sausages ');
await todoItems.nth(1).locator('.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.locator('.todo-list li');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).locator('.edit').fill('');
await todoItems.nth(1).locator('.edit').press('Enter');
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
TODO_ITEMS[2],
]);
});
test('should cancel edits on escape', async ({ page }) => {
const todoItems = page.locator('.todo-list li');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).locator('.edit').press('Escape');
await expect(todoItems).toHaveText(TODO_ITEMS);
});
});
test.describe('Counter', () => {
test('should display the current number of todo items', async ({ page }) => {
await page.locator('.new-todo').fill(TODO_ITEMS[0]);
await page.locator('.new-todo').press('Enter');
await expect(page.locator('.todo-count')).toContainText('1');
await page.locator('.new-todo').fill(TODO_ITEMS[1]);
await page.locator('.new-todo').press('Enter');
await expect(page.locator('.todo-count')).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.locator('.clear-completed')).toHaveText('Clear completed');
});
test('should remove completed items when clicked', async ({ page }) => {
const todoItems = page.locator('.todo-list li');
await todoItems.nth(1).locator('.toggle').check();
await page.locator('.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.locator('.clear-completed').click();
await expect(page.locator('.clear-completed')).toBeHidden();
});
});
test.describe('Persistence', () => {
test('should persist its data', async ({ page }) => {
for (const item of TODO_ITEMS.slice(0, 2)) {
await page.locator('.new-todo').fill(item);
await page.locator('.new-todo').press('Enter');
}
const todoItems = page.locator('.todo-list li');
await todoItems.nth(0).locator('.toggle').check();
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
await expect(todoItems).toHaveClass(['completed', '']);
// Ensure there is 1 completed item.
checkNumberOfCompletedTodosInLocalStorage(page, 1);
// Now reload.
await page.reload();
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
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 }) => {
await page.locator('.todo-list li .toggle').nth(1).check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.locator('.filters >> text=Active').click();
await expect(page.locator('.todo-list li')).toHaveCount(2);
await expect(page.locator('.todo-list li')).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
});
test('should respect the back button', async ({ page }) => {
await page.locator('.todo-list li .toggle').nth(1).check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await test.step('Showing all items', async () => {
await page.locator('.filters >> text=All').click();
await expect(page.locator('.todo-list li')).toHaveCount(3);
});
await test.step('Showing active items', async () => {
await page.locator('.filters >> text=Active').click();
});
await test.step('Showing completed items', async () => {
await page.locator('.filters >> text=Completed').click();
});
await expect(page.locator('.todo-list li')).toHaveCount(1);
await page.goBack();
await expect(page.locator('.todo-list li')).toHaveCount(2);
await page.goBack();
await expect(page.locator('.todo-list li')).toHaveCount(3);
});
test('should allow me to display completed items', async ({ page }) => {
await page.locator('.todo-list li .toggle').nth(1).check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.locator('.filters >> text=Completed').click();
await expect(page.locator('.todo-list li')).toHaveCount(1);
});
test('should allow me to display all items', async ({ page }) => {
await page.locator('.todo-list li .toggle').nth(1).check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.locator('.filters >> text=Active').click();
await page.locator('.filters >> text=Completed').click();
await page.locator('.filters >> text=All').click();
await expect(page.locator('.todo-list li')).toHaveCount(3);
});
test('should highlight the currently applied filter', async ({ page }) => {
await expect(page.locator('.filters >> text=All')).toHaveClass('selected');
await page.locator('.filters >> text=Active').click();
// Page change - active items.
await expect(page.locator('.filters >> text=Active')).toHaveClass('selected');
await page.locator('.filters >> text=Completed').click();
// Page change - completed items.
await expect(page.locator('.filters >> text=Completed')).toHaveClass('selected');
});
});
async function createDefaultTodos(page: Page) {
for (const item of TODO_ITEMS) {
await page.locator('.new-todo').fill(item);
await page.locator('.new-todo').press('Enter');
}
}
async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
return await page.waitForFunction(e => {
return JSON.parse(localStorage['react-todos']).length === e;
}, expected);
}
async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) {
return await page.waitForFunction(e => {
return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e;
}, expected);
}
async function checkTodosInLocalStorage(page: Page, title: string) {
return await page.waitForFunction(t => {
return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t);
}, title);
}

65
scripts/playwright/tests/multiSelect.spec.ts

@ -0,0 +1,65 @@
import { Page, test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard';
import { GridPage } from '../pages/Grid';
import setup from '../setup';
test.describe.serial('Multi select', () => {
let dashboard: DashboardPage, grid: GridPage;
let context: any;
test.beforeEach(async ({page}) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
await dashboard.createTable({ title: 'sheet1' });
grid = new GridPage(page);
await grid.column.create({ title: 'MultiSelect', type: 'MultiSelect' });
await grid.addNewRow({index: 0, title: "Row 0"});
})
test('Select and clear options and rename options', async () => {
await grid.cell.selectOption.select({index: 0, columnHeader: 'MultiSelect', option: 'Option 1', multiSelect: true});
await grid.cell.selectOption.verify({index: 0, columnHeader: 'MultiSelect', option: 'Option 1', multiSelect: true});
await grid.cell.selectOption.select({index: 0, columnHeader: 'MultiSelect', option: 'Option 2', multiSelect: true});
await grid.cell.selectOption.verify({index: 0, columnHeader: 'MultiSelect', option: 'Option 2', multiSelect: true});
await grid.addNewRow({index: 0, title: "Row 0"});
await grid.cell.selectOption.select({index: 1, columnHeader: 'MultiSelect', option: 'Option 1', multiSelect: true});
await grid.cell.selectOption.clear({index: 0, columnHeader: 'MultiSelect', multiSelect: true});
await grid.cell.click({index: 0, columnHeader: 'MultiSelect'});
await grid.column.selectOption.addOption({index: 2, option: 'Option 3', columnTitle: 'MultiSelect'});
await grid.cell.selectOption.select({index: 0, columnHeader: 'MultiSelect', option: 'Option 3', multiSelect: true});
await grid.cell.selectOption.verify({index: 0, columnHeader: 'MultiSelect', option: 'Option 3', multiSelect: true});
await grid.column.selectOption.editOption({index: 2, columnTitle: 'MultiSelect', newOption: 'New Option 3'});
await grid.cell.selectOption.verify({index: 0, columnHeader: 'MultiSelect', option: 'New Option 3', multiSelect: true});
await grid.cell.selectOption.verifyOptions({index: 0, columnHeader: 'MultiSelect', options: ['Option 1', 'Option 2', 'New Option 3']});
await grid.deleteRow(0);
await grid.deleteRow(0);
await grid.verifyRowDoesNotExist({index: 0});
});
test('Remove a option, reorder option and delete the column', async () => {
await grid.cell.selectOption.select({index: 0, columnHeader: 'MultiSelect', option: 'Option 1', multiSelect: true});
await grid.column.selectOption.addOption({index: 2, option: 'Option 3', columnTitle: 'MultiSelect'});
await grid.cell.selectOption.select({index: 0, columnHeader: 'MultiSelect', option: 'Option 3', multiSelect: true});
await grid.cell.selectOption.verify({index: 0, columnHeader: 'MultiSelect', option: 'Option 3', multiSelect: true});
await grid.column.selectOption.deleteOption({index: 2, columnTitle: 'MultiSelect'});
await grid.cell.selectOption.verifyNoOptionsSelected({index: 0, columnHeader: 'MultiSelect'});
await grid.column.selectOption.reorderOption({sourceOption: "Option 1", columnTitle: 'MultiSelect', destinationOption: "Option 2"});
await grid.cell.selectOption.verifyOptions({index: 0, columnHeader: 'MultiSelect', options: ['Option 2', 'Option 1']});
await grid.column.delete({title: 'MultiSelect'});
});
});

61
scripts/playwright/tests/singleSelect.spec.ts

@ -0,0 +1,61 @@
import { Page, test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard';
import { GridPage } from '../pages/Grid';
import setup from '../setup';
test.describe.serial('Single select', () => {
let dashboard: DashboardPage, grid: GridPage;
let context: any;
test.beforeEach(async ({page}) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
await dashboard.createTable({ title: 'sheet1' });
grid = new GridPage(page);
await grid.column.create({ title: 'SingleSelect', type: 'SingleSelect' });
await grid.addNewRow({index: 0, title: "Row 0"});
})
test('Select and clear options and rename options', async () => {
await grid.cell.selectOption.select({index: 0, columnHeader: 'SingleSelect', option: 'Option 1'});
await grid.cell.selectOption.verify({index: 0, columnHeader: 'SingleSelect', option: 'Option 1'});
await grid.cell.selectOption.select({index: 0, columnHeader: 'SingleSelect', option: 'Option 2'});
await grid.cell.selectOption.verify({index: 0, columnHeader: 'SingleSelect', option: 'Option 2'});
await grid.cell.selectOption.clear({index: 0, columnHeader: 'SingleSelect'});
await grid.cell.click({index: 0, columnHeader: 'SingleSelect'});
await grid.column.selectOption.addOption({index: 2, option: 'Option 3', columnTitle: 'SingleSelect'});
await grid.cell.selectOption.select({index: 0, columnHeader: 'SingleSelect', option: 'Option 3'});
await grid.cell.selectOption.verify({index: 0, columnHeader: 'SingleSelect', option: 'Option 3'});
await grid.column.selectOption.editOption({index: 2, columnTitle: 'SingleSelect', newOption: 'New Option 3'});
await grid.cell.selectOption.verify({index: 0, columnHeader: 'SingleSelect', option: 'New Option 3'});
await grid.cell.selectOption.verifyOptions({index: 0, columnHeader: 'SingleSelect', options: ['Option 1', 'Option 2', 'New Option 3']});
await grid.deleteRow(0);
await grid.verifyRowDoesNotExist({index: 0});
});
test('Remove a option, reorder option and delete the column', async () => {
await grid.cell.selectOption.select({index: 0, columnHeader: 'SingleSelect', option: 'Option 1'});
await grid.column.selectOption.addOption({index: 2, option: 'Option 3', columnTitle: 'SingleSelect'});
await grid.cell.selectOption.select({index: 0, columnHeader: 'SingleSelect', option: 'Option 3'});
await grid.cell.selectOption.verify({index: 0, columnHeader: 'SingleSelect', option: 'Option 3'});
await grid.column.selectOption.deleteOption({index: 2, columnTitle: 'SingleSelect'});
await grid.cell.selectOption.verifyNoOptionsSelected({index: 0, columnHeader: 'SingleSelect'});
await grid.column.selectOption.reorderOption({sourceOption: "Option 1", columnTitle: 'SingleSelect', destinationOption: "Option 2"});
await grid.cell.selectOption.verifyOptions({index: 0, columnHeader: 'SingleSelect', options: ['Option 2', 'Option 1']});
await grid.column.delete({title: 'SingleSelect'});
});
});
Loading…
Cancel
Save