diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..13d09495 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,23 @@ +name: E2E tests +on: + pull_request: + push: + branches: + - main +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: corepack enable + - uses: actions/setup-node@v3 + with: + node-version: 16 + cache: 'pnpm' + - name: Install dependencies + run: pnpm install + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + - name: Run Playwright tests + run: pnpm exec playwright test diff --git a/.gitignore b/.gitignore index cd1e2011..2cfe718a 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,7 @@ coverage *.sln *.sw? -.env \ No newline at end of file +.env +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/components.d.ts b/components.d.ts index bc2489ff..8fa004ea 100644 --- a/components.d.ts +++ b/components.d.ts @@ -25,7 +25,6 @@ declare module '@vue/runtime-core' { NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] NColorPicker: typeof import('naive-ui')['NColorPicker'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] - NCopyableInput: typeof import('naive-ui')['NCopyableInput'] NDatePicker: typeof import('naive-ui')['NDatePicker'] NDivider: typeof import('naive-ui')['NDivider'] NDynamicInput: typeof import('naive-ui')['NDynamicInput'] diff --git a/package.json b/package.json index bcbb3a07..e174aa35 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "preview": "vite preview --port 5050", "test": "npm run test:unit", "test:unit": "vitest --environment jsdom", + "test:e2e": "playwright test", "coverage": "vitest run --coverage", "typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", "lint": "eslint src --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --ignore-path .gitignore", @@ -75,6 +76,7 @@ "vue-router": "^4.1.6" }, "devDependencies": { + "@playwright/test": "^1.32.2", "@rushstack/eslint-patch": "^1.2.0", "@types/bcryptjs": "^2.4.2", "@types/crypto-js": "^4.1.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..cb6d3072 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,80 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './src', + testMatch: /.*\.e2e\.(spec\.)?ts/, + /* 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: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + + testIdAttribute: 'data-test-id', + }, + + /* 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: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run dev', + url: 'http://127.0.0.1:3000', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f07788b1..bbd815d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ dependencies: version: 4.1.6(vue@3.2.47) devDependencies: + '@playwright/test': + specifier: ^1.32.2 + version: 1.32.2 '@rushstack/eslint-patch': specifier: ^1.2.0 version: 1.2.0 @@ -1721,6 +1724,17 @@ packages: tslib: 2.5.0 dev: true + /@playwright/test@1.32.2: + resolution: {integrity: sha512-nhaTSDpEdTTttdkDE8Z6K3icuG1DVRxrl98Qq0Lfc63SS9a2sjc9+x8ezysh7MzCKz6Y+nArml3/mmt+gqRmQQ==} + engines: {node: '>=14'} + hasBin: true + dependencies: + '@types/node': 16.18.22 + playwright-core: 1.32.2 + optionalDependencies: + fsevents: 2.3.2 + dev: true + /@polka/url@1.0.0-next.21: resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} dev: true @@ -6800,6 +6814,12 @@ packages: engines: {node: '>=10'} dev: false + /playwright-core@1.32.2: + resolution: {integrity: sha512-zD7aonO+07kOTthsrCR3YCVnDcqSHIJpdFUtZEMOb6//1Rc7/6mZDRdw+nlzcQiQltOOsiqI3rrSyn/SlyjnJQ==} + engines: {node: '>=14'} + hasBin: true + dev: true + /pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} diff --git a/scripts/create-tool.mjs b/scripts/create-tool.mjs index 36a20d8e..a6e16f33 100644 --- a/scripts/create-tool.mjs +++ b/scripts/create-tool.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from 'url'; const currentDirname = dirname(fileURLToPath(import.meta.url)); const toolsDir = join(currentDirname, '..', 'src', 'tools'); +// eslint-disable-next-line no-undef const toolName = process.argv[2]; if (!toolName) { @@ -73,6 +74,28 @@ import { expect, describe, it } from 'vitest'; `, ); +createToolFile( + `${toolName}.e2e.spec.ts`, + ` +import { test, expect } from '@playwright/test'; + +test.describe('Tool - ${toolNameTitleCase}', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/${toolName}'); + }); + + test('Has correct title', async ({ page }) => { + await expect(page).toHaveTitle('${toolNameTitleCase} - IT Tools'); + }); + + test('', async ({ page }) => { + + }); +}); + +`, +); + const toolsIndex = join(toolsDir, 'index.ts'); const indexContent = await readFile(toolsIndex, { encoding: 'utf-8' }).then((r) => r.split('\n')); diff --git a/src/tools/otp-code-generator-and-validator/otp-code-generator.e2e.spec.ts b/src/tools/otp-code-generator-and-validator/otp-code-generator.e2e.spec.ts new file mode 100644 index 00000000..6188f82f --- /dev/null +++ b/src/tools/otp-code-generator-and-validator/otp-code-generator.e2e.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Tool - OTP code generator', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/otp-generator'); + }); + + test('Has title', async ({ page }) => { + await expect(page).toHaveTitle('OTP code generator - IT Tools'); + }); + + test('Secret hexa value is computed from provided secret', async ({ page }) => { + await page.getByPlaceholder('Paste your TOTP secret...').fill('ITTOOLS'); + + const secretInHex = await page.getByPlaceholder('Secret in hex will be displayed here').inputValue(); + + expect(secretInHex).toEqual('44e6e72e02'); + }); + + test('OTP a generated from the provided secret', async ({ page }) => { + page.evaluate(() => { + Date.now = () => 1609477200000; //Jan 1, 2021 + }); + + await page.getByPlaceholder('Paste your TOTP secret...').fill('ITTOOLS'); + + const previousOtp = await page.getByTestId('previous-otp').innerText(); + const currentOtp = await page.getByTestId('current-otp').innerText(); + const nextOtp = await page.getByTestId('next-otp').innerText(); + + expect(previousOtp.trim()).toEqual('028034'); + expect(currentOtp.trim()).toEqual('162195'); + expect(nextOtp.trim()).toEqual('452815'); + }); + + test('You can generate a new random secret', async ({ page }) => { + const initialSecret = await page.getByPlaceholder('Paste your TOTP secret...').inputValue(); + await page + .locator('div') + .filter({ hasText: /^Secret$/ }) + .getByRole('button') + .click(); + + const newSecret = await page.getByPlaceholder('Paste your TOTP secret...').inputValue(); + + expect(newSecret).not.toEqual(initialSecret); + }); +}); diff --git a/src/tools/otp-code-generator-and-validator/token-display.vue b/src/tools/otp-code-generator-and-validator/token-display.vue index 6ead65c8..ce11ccd5 100644 --- a/src/tools/otp-code-generator-and-validator/token-display.vue +++ b/src/tools/otp-code-generator-and-validator/token-display.vue @@ -8,13 +8,21 @@
{{ previousCopied ? 'Copied !' : 'Copy previous OTP' }}
@@ -22,7 +30,9 @@
{{ nextCopied ? 'Copied !' : 'Copy next OTP' }}
diff --git a/src/tools/token-generator/token-generator.e2e.spec.ts b/src/tools/token-generator/token-generator.e2e.spec.ts new file mode 100644 index 00000000..905a81cc --- /dev/null +++ b/src/tools/token-generator/token-generator.e2e.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Tool - Token generator', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/token-generator'); + }); + + test('Has title', async ({ page }) => { + await expect(page).toHaveTitle('Token generator - IT Tools'); + }); + + test('New token on refresh', async ({ page }) => { + const initialToken = await page.getByPlaceholder('The token...').inputValue(); + await page.getByRole('button', { name: 'Refresh' }).click(); + const newToken = await page.getByPlaceholder('The token...').inputValue(); + + expect(newToken).not.toEqual(initialToken); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..1c0d1e52 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { configDefaults, defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + exclude: [...configDefaults.exclude, '**/*.e2e.spec.ts'], + }, +});