diff --git a/.devcontainer/cpp-test/devcontainer.json b/.devcontainer/cpp-test/devcontainer.json new file mode 100644 index 00000000..02940f28 --- /dev/null +++ b/.devcontainer/cpp-test/devcontainer.json @@ -0,0 +1,11 @@ +{ + "image": "ghcr.io/philips-software/amp-devcontainer-cpp:${localEnv:IMAGE_VERSION}", + "workspaceFolder": "/workspaces/amp-devcontainer/.devcontainer/cpp/e2e/workspace", + "customizations": { + "vscode": { + "settings": { + "cmake.automaticReconfigure": false + } + } + } +} diff --git a/.devcontainer/cpp/devcontainer.json b/.devcontainer/cpp/devcontainer.json index 539e02c7..565d067a 100644 --- a/.devcontainer/cpp/devcontainer.json +++ b/.devcontainer/cpp/devcontainer.json @@ -3,12 +3,20 @@ "dockerfile": "Dockerfile", "context": "../.." }, + "forwardPorts": [6080], "remoteEnv": { - "CONTAINER_FLAVOR": "cpp" + "CONTAINER_FLAVOR": "cpp", + "NODE_EXTRA_CA_CERTS": "/usr/local/share/ca-certificates/Cisco_Umbrella_Root_CA.crt" }, "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ], + "features": { + "ghcr.io/devcontainers/features/desktop-lite:1.2.4": {}, + "ghcr.io/devcontainers/features/github-cli:1.0.13": {}, + "ghcr.io/devcontainers/features/node:1.5.0": {} + }, + "postCreateCommand": "npm install && npx playwright install --with-deps", "customizations": { "vscode": { "settings": { @@ -23,6 +31,7 @@ "matepek.vscode-catch2-test-adapter@4.12.0", "mhutchie.git-graph@1.30.0", "ms-azuretools.vscode-docker@1.29.1", + "ms-playwright.playwright@1.1.7", "ms-vscode.cmake-tools@1.18.42", "ms-vscode.cpptools@1.20.5", "sonarsource.sonarlint-vscode@4.7.0", diff --git a/.devcontainer/cpp/e2e/playwright.config.ts b/.devcontainer/cpp/e2e/playwright.config.ts new file mode 100644 index 00000000..939d644b --- /dev/null +++ b/.devcontainer/cpp/e2e/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'path'; + +require('dotenv').config(); + +export const STORAGE_STATE = path.join(__dirname, 'playwright/.auth/user.json'); + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: 'list', + use: { + trace: 'on-first-retry' + }, + projects: [ + { name: 'setup', testMatch: '**/*.setup.ts' }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: STORAGE_STATE + }, + dependencies: ['setup'] + } + ] +}); diff --git a/.devcontainer/cpp/e2e/tests/authentication.setup.ts b/.devcontainer/cpp/e2e/tests/authentication.setup.ts new file mode 100644 index 00000000..31761201 --- /dev/null +++ b/.devcontainer/cpp/e2e/tests/authentication.setup.ts @@ -0,0 +1,29 @@ +import { test as setup } from '@playwright/test'; +import * as OTPAuth from 'otpauth'; +import { STORAGE_STATE } from '../playwright.config'; + +setup('authenticate', async ({ page }) => { + await page.goto('https://github.com/login'); + await page.getByLabel('Username or email address').fill(process.env.GITHUB_USER!); + await page.getByLabel('Password').fill(process.env.GITHUB_PASSWORD!); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + + let totp = new OTPAuth.TOTP({ + issuer: 'GitHub', + label: 'GitHub', + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: process.env.GITHUB_TOTP_SECRET! + }); + + let code = totp.generate(); + await page.getByPlaceholder('XXXXXX').fill(code); + + // Wait until the page receives the cookies. + // + // Sometimes login flow sets cookies in the process of several redirects. + // Wait for the final URL to ensure that the cookies are actually set. + await page.waitForURL('https://github.com/'); + await page.context().storageState({ path: STORAGE_STATE }); +}); diff --git a/.devcontainer/cpp/e2e/tests/codespace.pom.ts b/.devcontainer/cpp/e2e/tests/codespace.pom.ts new file mode 100644 index 00000000..e521cd15 --- /dev/null +++ b/.devcontainer/cpp/e2e/tests/codespace.pom.ts @@ -0,0 +1,103 @@ +import { test, expect, type Page, type Locator } from '@playwright/test'; + +type CommandAndPrompt = { + command: string, + prompt?: string +}; + +export class CodespacePage { + readonly page: Page; + readonly outputPanel: Locator; + readonly terminal: Locator; + + constructor(page: Page) { + this.page = page; + this.outputPanel = page.locator('[id="workbench.panel.output"]'); + this.terminal = page.locator('.terminal-widget-container').first(); + } + + async goto() { + await this.page.goto('https://' + process.env.CODESPACE_NAME + '.github.dev'); + } + + /** + * Wait for the extensions to be active in the Codespace. + * + * This method is used to verify that the extensions in `extensions` are active in the Codespace. + * Used when waiting for the Codespace to be ready for testing. As the + * extensions are typically activated last, before the Codespace is ready for use. + * + * **Usage** + * + * ```ts + * const codespace = new CodespacePage(page); + * await codespace.areExtensionsActive(['SonarLint', 'CMake', 'Live Share', 'GitHub Pull Requests']); + * ``` + * + * @param extensions - The list of extensions to wait for. + */ + async areExtensionsActive(extensions: string[]) { + test.setTimeout(3 * 60 * 1000); + + for (const plugin of extensions) { + await expect(this.page.getByRole('tab', { name: plugin }).locator('a')).toBeVisible({ timeout: 5 * 60 * 1000 }); + } + + await expect(this.page.getByRole('button', { name: 'Activating Extensions...' })).toBeHidden(); + } + + /** + * Executes the given commands in the terminal. + * + * **Usage** + * + * ```ts + * const codespace = new CodespacePage(page); + * await codespace.executeInTerminal('git clean -fdx'); + * ``` + * + * @param commands - The commands to execute in the terminal. It can be a single command or an array of commands. + */ + async executeInTerminal(commands: string | string[]) { + await this.page.keyboard.press('Control+Shift+`'); + await expect(this.page.locator('.terminal-wrapper.active')).toBeVisible(); + + for (const command of Array.isArray(commands) ? [...commands + 'exit'] : [commands, 'exit']) { + await this.terminal.pressSequentially(command); + await this.terminal.press('Enter'); + } + } + + /** + * Executes the given commands in the command palette. + * + * This method waits for `prompt` to appear and then types `command` and presses Enter. + * When no prompt is given the default prompt is used. + * + * @param commands - The commands to execute in the command palette. It can be a single command or an array of commands. + */ + async executeFromCommandPalette(commands: CommandAndPrompt | CommandAndPrompt[]) { + await this.page.keyboard.press('Control+Shift+P'); + + for (const command of Array.isArray(commands) ? commands : [commands]) { + let prompt = this.page.getByPlaceholder(command.prompt || 'Type the name of a command to run'); + + await prompt.pressSequentially(command.command); + await prompt.press('Enter'); + } + } + + /** + * Opens the tab with the given name. + * + * @param name - The name of the tab to open. + */ + async openTabByName(name: string) { + await this.page.getByRole('tab', { name: name }).locator('a').click(); + } + + async openFileInEditor(name: string) { + await this.page.getByRole('treeitem', { name: name }).locator('a').click(); + await expect(this.page.locator('[id="workbench.parts.editor"]')).toContainText(name); + } +} diff --git a/.devcontainer/cpp/e2e/tests/smoke.spec.ts b/.devcontainer/cpp/e2e/tests/smoke.spec.ts new file mode 100644 index 00000000..bee322fc --- /dev/null +++ b/.devcontainer/cpp/e2e/tests/smoke.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test'; +import { CodespacePage } from './codespace.pom'; + +test.beforeEach(async ({ page }) => { + const codespace = new CodespacePage(page); + + await codespace.goto(); + await codespace.areExtensionsActive(['Testing', 'SonarLint', 'CMake', 'Live Share', 'GitHub Pull Requests']); + await codespace.executeInTerminal('git clean -fdx'); +}); + +test.describe('CMake', () => { + test('should succesfully build without selecting configuration', async ({ page }) => { + const codespace = new CodespacePage(page); + + await page.getByRole('button', { name: 'Build the selected target' }).click(); + await page.getByLabel('host, Build for host').locator('a').click(); + await expect(codespace.outputPanel).toContainText('Build finished with exit code 0', { timeout: 5 * 60 * 1000 }); + }); + + test('should succesfully build after selecting configuration', async ({ page }) => { + const codespace = new CodespacePage(page); + + await codespace.openTabByName('CMake'); + await expect(page.getByRole('treeitem', { name: 'Change Configure Preset' })).toContainText('[No Configure Preset Selected]'); + await expect(page.getByRole('treeitem', { name: 'Change Build Preset' })).toContainText('[No Build Preset Selected]'); + + await codespace.executeFromCommandPalette([{ command: 'CMake: Select Configure Preset' }, + { command: 'host', prompt: 'Select a configure preset' }]); + await expect(page.getByRole('treeitem', { name: 'Change Configure Preset' })).toContainText('host'); + + await codespace.executeFromCommandPalette([{ command: 'CMake: Select Build Preset' }, + { command: 'host-Release', prompt: 'Select a build preset' }]); + await expect(page.getByRole('treeitem', { name: 'Change Build Preset' })).toContainText('host-Release'); + + await page.getByRole('button', { name: 'Build the selected target' }).click(); + await expect(codespace.outputPanel).toContainText('Build finished with exit code 0', { timeout: 5 * 60 * 1000 }); + }); +}); diff --git a/.devcontainer/cpp/e2e/workspace/CMakeLists.txt b/.devcontainer/cpp/e2e/workspace/CMakeLists.txt new file mode 100644 index 00000000..b67fe424 --- /dev/null +++ b/.devcontainer/cpp/e2e/workspace/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.24) + +project(e2e-test VERSION 1.0.0) + +add_executable(e2e-test main.cpp) diff --git a/.devcontainer/cpp/e2e/workspace/CMakePresets.json b/.devcontainer/cpp/e2e/workspace/CMakePresets.json new file mode 100644 index 00000000..a4f1e919 --- /dev/null +++ b/.devcontainer/cpp/e2e/workspace/CMakePresets.json @@ -0,0 +1,42 @@ +{ + "version": 3, + "configurePresets": [ + { + "name": "defaults", + "hidden": true, + "binaryDir": "${sourceDir}/build/${presetName}", + "generator": "Ninja Multi-Config", + "cacheVariables": { + "CMAKE_CONFIGURATION_TYPES": "Debug;Release;RelWithDebInfo;MinSizeRel" + } + }, + { + "name": "host", + "displayName": "host", + "description": "Build for host", + "inherits": "defaults" + } + ], + "buildPresets": [ + { + "name": "host-Debug", + "configuration": "Debug", + "configurePreset": "host" + }, + { + "name": "host-Release", + "configuration": "Release", + "configurePreset": "host" + }, + { + "name": "host-RelWithDebInfo", + "configuration": "RelWithDebInfo", + "configurePreset": "host" + }, + { + "name": "host-MinSizeRel", + "configuration": "MinSizeRel", + "configurePreset": "host" + } + ] +} diff --git a/.devcontainer/cpp/e2e/workspace/main.cpp b/.devcontainer/cpp/e2e/workspace/main.cpp new file mode 100644 index 00000000..37a56944 --- /dev/null +++ b/.devcontainer/cpp/e2e/workspace/main.cpp @@ -0,0 +1,12 @@ +#include + +void SmellyFunction() +{ + auto array = new int[10]; +} + +int main() +{ + std::cout << "Hello World!" << std::endl; + return 0; +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b98be326..503219a8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ updates: - package-ecosystem: github-actions directory: / schedule: - interval: daily + interval: weekly groups: github-actions: update-types: @@ -16,8 +16,16 @@ updates: - package-ecosystem: docker directory: .devcontainer schedule: - interval: daily + interval: weekly + - package-ecosystem: devcontainers + directory: .devcontainer + schedule: + interval: weekly + - package-ecosystem: npm + directory: / + schedule: + interval: weekly - package-ecosystem: pip directory: .devcontainer schedule: - interval: daily + interval: weekly diff --git a/.github/workflows/acceptance-test.yml b/.github/workflows/acceptance-test.yml new file mode 100644 index 00000000..20bb9ecc --- /dev/null +++ b/.github/workflows/acceptance-test.yml @@ -0,0 +1,52 @@ +--- +name: Acceptance Test + +on: + workflow_call: + inputs: + flavor: + required: true + type: string + +concurrency: + group: ${{ github.workflow }} + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v9.0.1 + with: + egress-policy: audit + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + persist-credentials: false + - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + with: + node-version: 20 + - run: npm ci + - run: npx playwright install --with-deps + # Create a GitHub Codespace and communicate the image version via a Codespace secret (should be a Codespace environment variable). + # This secret is used by devcontainer.json, as such it is a resource that should not be used concurrently. + - run: | + set -Eeuo pipefail + gh secret set -a codespaces IMAGE_VERSION --body "pr-${{ github.event.pull_request.number }}" + echo CODESPACE_NAME="$(gh codespace create -R "${{ github.repository }}" -b "$HEAD_REF" -m basicLinux32gb --devcontainer-path ".devcontainer/${{ inputs.flavor }}-test/devcontainer.json" --idle-timeout 10m --retention-period 1h)" >> "$GITHUB_ENV" + env: + GH_TOKEN: ${{ secrets.TEST_GITHUB_TOKEN }} + HEAD_REF: ${{ github.head_ref }} + - run: cd .devcontainer/${{ inputs.flavor }}/e2e && npm test + env: + GITHUB_USER: ${{ secrets.TEST_GITHUB_USER }} + GITHUB_PASSWORD: ${{ secrets.TEST_GITHUB_PASSWORD }} + GITHUB_TOTP_SECRET: ${{ secrets.TEST_GITHUB_TOTP_SECRET }} + - run: | + set -Eeuo pipefail + gh codespace delete --force --codespace "$CODESPACE_NAME" + gh secret set -a codespaces IMAGE_VERSION --body "latest" + if: always() + env: + GH_TOKEN: ${{ secrets.TEST_GITHUB_TOKEN }} diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 43758c6e..098577e8 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -33,6 +33,9 @@ jobs: matrix: flavor: ["cpp", "rust"] steps: + - uses: step-security/harden-runner@0d381219ddf674d61a7572ddd19d7941e271515c # v9.0.1 + with: + egress-policy: audit - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: persist-credentials: false @@ -126,3 +129,10 @@ jobs: DIGEST: ${{ steps.build-and-push.outputs.digest }} run: | cosign sign --yes --recursive "${{ env.REGISTRY }}/${{ github.repository }}-${{ matrix.flavor }}@${DIGEST}" + acceptance-test: + if: github.event_name == 'pull_request' + needs: build-push + secrets: inherit + uses: ./.github/workflows/acceptance-test.yml + with: + flavor: cpp diff --git a/.gitignore b/.gitignore index 25de2ddf..1f58bd88 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,11 @@ build/ megalinter-reports/ mutants.out/ +node_modules/ +playwright-report/ target/ +test-results/ +.env *.profdata *.profraw +**/playwright/.auth/user.json diff --git a/README.md b/README.md index 74e2ebd2..f2b965dc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # amp-devcontainer -[![Linting & Formatting](https://github.com/philips-software/amp-devcontainer/actions/workflows/linting-formatting.yml/badge.svg)](https://github.com/philips-software/amp-devcontainer/actions/workflows/linting-formatting.yml) [![Build & Push](https://github.com/philips-software/amp-devcontainer/actions/workflows/build-push.yml/badge.svg?branch=main)](https://github.com/philips-software/amp-devcontainer/actions/workflows/build-push.yml) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/philips-software/amp-devcontainer/badge)](https://securityscorecards.dev/viewer/?uri=github.com/philips-software/amp-devcontainer) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9267/badge)](https://www.bestpractices.dev/projects/9267) +[![Linting & Formatting](https://github.com/philips-software/amp-devcontainer/actions/workflows/linting-formatting.yml/badge.svg)](https://github.com/philips-software/amp-devcontainer/actions/workflows/linting-formatting.yml) [![Build & Push](https://github.com/philips-software/amp-devcontainer/actions/workflows/build-push.yml/badge.svg)](https://github.com/philips-software/amp-devcontainer/actions/workflows/build-push.yml) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9267/badge)](https://www.bestpractices.dev/projects/9267) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/philips-software/amp-devcontainer/badge)](https://securityscorecards.dev/viewer/?uri=github.com/philips-software/amp-devcontainer) ## Overview @@ -107,8 +107,22 @@ This project uses [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.htm The containers can be built and tested locally by importing this repository in VS Code with the [Remote Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) plug-in installed. As a prerequisite Docker needs to be installed on the host system. Alternatively a GitHub Codespace can be started. +#### Running the Integration Tests + A test task is available to run the included `bats` tests. Choose `Tasks: Run Test Task` from the command pallette (Ctrl + Shift + P). +#### Running the Acceptance Tests + +Create an .env file with the following contents, this assumes a GitHub account that has rights to create a Codespace on this repository and is configured for time-based one-time password (TOTP) two-factor authentication (2FA). + +```dotenv +GITHUB_USER= +GITHUB_PASSWORD= +GITHUB_TOTP_SECRET= +``` + +Test can now be run using the Test Explorer. The user interface is available on port 6080 by-default. When port 6080 is already taken another port will be exposed. This can be seen with the Ports view (Ctrl + Shift + P, Ports: Focus on Ports View). + ## Reporting vulnerabilities If you find a vulnerability, please report it to us! diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..b2b03701 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,125 @@ +{ + "name": "amp-devcontainer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@playwright/test": "^1.45.2", + "@types/node": "^20.14.11", + "dotenv": "^16.4.5", + "otpauth": "^9.3.1" + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@playwright/test": { + "version": "1.45.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.2.tgz", + "integrity": "sha512-JxG9eq92ET75EbVi3s+4sYbcG7q72ECeZNbdBlaMkGcNbiDQ4cAi8U2QP5oKkOx+1gpaiL1LDStmzCaEM1Z6fQ==", + "dev": true, + "dependencies": { + "playwright": "1.45.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.14.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", + "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/otpauth": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.3.1.tgz", + "integrity": "sha512-E6d2tMxPofHNk4sRFp+kqW7vQ+WJGO9VLI2N/W00DnI+ThskU12Qa10kyNSGklrzhN5c+wRUsN4GijVgCU2N9w==", + "dev": true, + "dependencies": { + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, + "node_modules/playwright": { + "version": "1.45.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.2.tgz", + "integrity": "sha512-ReywF2t/0teRvNBpfIgh5e4wnrI/8Su8ssdo5XsQKpjxJj+jspm00jSoz9BTg91TT0c9HRjXO7LBNVrgYj9X0g==", + "dev": true, + "dependencies": { + "playwright-core": "1.45.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.45.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.2.tgz", + "integrity": "sha512-ha175tAWb0dTK0X4orvBIqi3jGEt701SMxMhyujxNrgd8K0Uy5wMSwwcQHtyB4om7INUkfndx02XnQ2p6dvLDw==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..191d8d88 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "devDependencies": { + "@playwright/test": "^1.45.2", + "@types/node": "^20.14.11", + "dotenv": "^16.4.5", + "otpauth": "^9.3.1" + }, + "scripts": { + "test": "cd $INIT_CWD && npx playwright test" + } +}