diff --git a/.github/actions/actionsLib.mjs b/.github/actions/actionsLib.mjs new file mode 100644 index 000000000000..aebd56b2ef7d --- /dev/null +++ b/.github/actions/actionsLib.mjs @@ -0,0 +1,99 @@ +/* eslint-env node */ +// @ts-check + +import { fileURLToPath } from 'node:url' + +import { getExecOutput } from '@actions/exec' +import { hashFiles } from '@actions/glob' + +/** + * @typedef {import('@actions/exec').ExecOptions} ExecOptions + */ + +export const REDWOOD_FRAMEWORK_PATH = fileURLToPath(new URL('../../', import.meta.url)) + +/** + * @param {string} command + * @param {ExecOptions} options + */ +function execWithEnv(command, { env = {}, ...rest } = {}) { + return getExecOutput( + command, + undefined, + { + // @ts-expect-error TS doesn't like spreading process.env here but it's fine for our purposes. + env: { + ...process.env, + ...env + }, + ...rest + } + ) +} + +/** + * @param {string} cwd + */ +export function createExecWithEnvInCwd(cwd) { + /** + * @param {string} command + * @param {Omit} options + */ + return function (command, options = {}) { + return execWithEnv(command, { cwd, ...options }) + } +} + +export const execInFramework = createExecWithEnvInCwd(REDWOOD_FRAMEWORK_PATH) + +/** + * @param {string} redwoodProjectCwd + */ +export function projectDeps(redwoodProjectCwd) { + return execInFramework('yarn project:deps', { env: { RWJS_CWD: redwoodProjectCwd } }) +} + +/** + * @param {string} redwoodProjectCwd + */ +export function projectCopy(redwoodProjectCwd) { + return execInFramework('yarn project:copy', { env: { RWJS_CWD: redwoodProjectCwd } }) +} + +/** + * @param {string} prefix + */ +export async function createCacheKeys(prefix) { + const baseKey = [ + prefix, + process.env.RUNNER_OS, + // @ts-expect-error not sure how to change the lib compiler option to es2021+ here. + process.env.GITHUB_REF.replaceAll('/', '-'), + ].join('-') + + const dependenciesKey = [ + baseKey, + 'dependencies', + await hashFiles(['yarn.lock', '.yarnrc.yml'].join('\n')), + ].join('-') + + const distKey = [ + dependenciesKey, + 'dist', + await hashFiles([ + 'package.json', + 'babel.config.js', + 'tsconfig.json', + 'tsconfig.compilerOption.json', + 'nx.json', + 'lerna.json', + 'packages', + ].join('\n')) + ].join('-') + + return { + baseKey, + dependenciesKey, + distKey + } +} diff --git a/.github/actions/set-up-test-project/action.yaml b/.github/actions/set-up-test-project/action.yaml new file mode 100644 index 000000000000..8e78099878cf --- /dev/null +++ b/.github/actions/set-up-test-project/action.yaml @@ -0,0 +1,11 @@ +name: Set up test project +description: Sets up the test project fixture in CI for smoke tests and CLI checks + +runs: + # `node18` isn't supported yet + using: node16 + main: 'setUpTestProject.mjs' + +outputs: + test-project-path: + description: Path to the test project diff --git a/.github/actions/set-up-test-project/setUpTestProject.mjs b/.github/actions/set-up-test-project/setUpTestProject.mjs new file mode 100644 index 000000000000..2ecc2c8a1226 --- /dev/null +++ b/.github/actions/set-up-test-project/setUpTestProject.mjs @@ -0,0 +1,111 @@ +/* eslint-env node */ +// @ts-check + +import path from 'node:path' + +import cache from '@actions/cache' +import core from '@actions/core' + +import fs from 'fs-extra' + +import { + createCacheKeys, + createExecWithEnvInCwd, + projectCopy, + projectDeps, + REDWOOD_FRAMEWORK_PATH, +} from '../actionsLib.mjs' + +const TEST_PROJECT_PATH = path.join( + path.dirname(process.cwd()), + 'test-project' +) + +core.setOutput('test-project-path', TEST_PROJECT_PATH) + +const { + dependenciesKey, + distKey +} = await createCacheKeys('test-project') + +/** + * @returns {Promise} + */ +async function main() { + const distCacheKey = await cache.restoreCache([TEST_PROJECT_PATH], distKey) + + if (distCacheKey) { + console.log(`Cache restored from key: ${distKey}`) + return + } + + const dependenciesCacheKey = await cache.restoreCache([TEST_PROJECT_PATH], dependenciesKey) + + if (dependenciesCacheKey) { + console.log(`Cache restored from key: ${dependenciesKey}`) + await sharedTasks() + } else { + console.log(`Cache not found for input keys: ${distKey}, ${dependenciesKey}`) + await setUpTestProject() + } + + await cache.saveCache([TEST_PROJECT_PATH], distKey) + console.log(`Cache saved with key: ${distKey}`) +} + +/** + * @returns {Promise} + */ +async function setUpTestProject() { + const TEST_PROJECT_FIXTURE_PATH = path.join( + REDWOOD_FRAMEWORK_PATH, + '__fixtures__', + 'test-project' + ) + + console.log(`Creating project at ${TEST_PROJECT_PATH}`) + console.log() + await fs.copy(TEST_PROJECT_FIXTURE_PATH, TEST_PROJECT_PATH) + + console.log(`Adding framework dependencies to ${TEST_PROJECT_PATH}`) + await projectDeps(TEST_PROJECT_PATH) + console.log() + + console.log(`Installing node_modules in ${TEST_PROJECT_PATH}`) + await execInProject('yarn install') + console.log() + + await cache.saveCache([TEST_PROJECT_PATH], dependenciesKey) + console.log(`Cache saved with key: ${dependenciesKey}`) + + await sharedTasks() +} + +const execInProject = createExecWithEnvInCwd(TEST_PROJECT_PATH) + +/** + * @returns {Promise} + */ +async function sharedTasks() { + console.log('Copying framework packages to project') + await projectCopy(TEST_PROJECT_PATH) + console.log() + + console.log('Generating dbAuth secret') + const { stdout } = await execInProject( + 'yarn rw g secret --raw', + { silent: true } + ) + fs.appendFileSync( + path.join(TEST_PROJECT_PATH, '.env'), + `SESSION_SECRET='${stdout}'` + ) + console.log() + + console.log('Running prisma migrate reset') + await execInProject( + 'yarn rw prisma migrate reset --force', + ) +} + +main() diff --git a/.github/actions/set-up-yarn-cache/action.yml b/.github/actions/set-up-yarn-cache/action.yml new file mode 100644 index 000000000000..8bafd1508d4a --- /dev/null +++ b/.github/actions/set-up-yarn-cache/action.yml @@ -0,0 +1,37 @@ +name: Set up yarn cache +description: | + Sets up caching for yarn install steps. + Caches yarn's cache directory, install state, and node_modules. + +runs: + using: composite + + steps: + # We try to cache and restore yarn's cache directory and install state to speed up the yarn install step. + # Caching yarn's cache directory avoids its fetch step. + - name: ๐Ÿ“ Get yarn cache directory + id: get-yarn-cache-directory + run: echo "CACHE_DIRECTORY=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + shell: bash + + # If the primary key doesn't match, the cache will probably be stale or incomplete, + # but still worth restoring for the yarn install step. + - name: โ™ป๏ธ Restore yarn cache + uses: actions/cache@v3 + with: + path: ${{ steps.get-yarn-cache-directory.outputs.CACHE_DIRECTORY }} + key: yarn-cache-${{ runner.os }}-${{ hashFiles('yarn.lock', '.yarnrc.yml') }} + restore-keys: yarn-cache-${{ runner.os }} + + # We avoid restore-keys for these steps because it's important to just start from scratch for new PRs. + - name: โ™ป๏ธ Restore yarn install state + uses: actions/cache@v3 + with: + path: .yarn/install-state.gz + key: yarn-install-state-${{ runner.os }}-${{ hashFiles('yarn.lock', '.yarnrc.yml') }} + + - name: โ™ป๏ธ Restore node_modules + uses: actions/cache@v3 + with: + path: '**/node_modules' + key: yarn-node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock', '.yarnrc.yml') }} diff --git a/.github/actions/setup_test_project/action.yaml b/.github/actions/setup_test_project/action.yaml deleted file mode 100644 index 31eb4478af58..000000000000 --- a/.github/actions/setup_test_project/action.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: 'Setup test project' -description: 'Setup for CLI checks and telemetry benchmarks' -outputs: - test_project_path: - description: 'Path to the test project' -runs: - # `node18` isn't supported yet - using: node16 - main: 'setup_test_project.mjs' diff --git a/.github/actions/setup_test_project/setup_test_project.mjs b/.github/actions/setup_test_project/setup_test_project.mjs deleted file mode 100644 index 4ae944c9ea16..000000000000 --- a/.github/actions/setup_test_project/setup_test_project.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import path from 'node:path' -import fs from 'fs-extra' - -import { exec } from '@actions/exec' -import * as core from '@actions/core' - -// @NOTE: do not use os.tmpdir() -// Vite does not play well in the smoke tests with the temp dir -const test_project_path = path.join( - path.dirname(process.cwd()), - 'test-project' -) - -console.log(`Creating test project at ${test_project_path}....`) - -core.setOutput('test_project_path', test_project_path) - -await exec(`yarn build:test-project --ts --link ${test_project_path}`) - -try { - if ( - !fs.existsSync(path.join(test_project_path, 'web/tsconfig.json')) || - !fs.existsSync(path.join(test_project_path, 'api/tsconfig.json')) - ) { - throw ('Test-project is not TypeScript') - } -} catch(e) { - console.log('********************************') - console.error('\nError: Test-project is expected to be TypeScript\nExiting test-project setup.\n') - console.log('********************************') - process.exit(1) -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 994d47af7ee7..ecf449b35d0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,27 +165,44 @@ jobs: steps: - run: echo "Only doc changes" - smoke-test: + smoke-tests: needs: check + strategy: matrix: os: [ubuntu-latest, windows-latest] - fail-fast: false - name: ๐Ÿ‘€ Smoke test / ${{ matrix.os }} / node 18 latest + + name: ๐Ÿ”„ Smoke tests / ${{ matrix.os }} / node 18 latest runs-on: ${{ matrix.os }} + env: REDWOOD_CI: 1 REDWOOD_VERBOSE_TELEMETRY: 1 # This makes sure that playwright dependencies are cached in node_modules. PLAYWRIGHT_BROWSERS_PATH: 0 + steps: - uses: actions/checkout@v3 - - name: ๐Ÿงถ Set up job - uses: ./.github/actions/set-up-job - - name: ๐ŸŒฒ Setup test project - id: setup_test_project - uses: ./.github/actions/setup_test_project + - name: โฌข Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: ๐Ÿˆ Set up yarn cache + uses: ./.github/actions/set-up-yarn-cache + + - name: ๐Ÿˆ Yarn install + run: yarn install --inline-builds + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: ๐Ÿ”จ Build + run: yarn build + + - name: ๐ŸŒฒ Set up test project + id: set-up-test-project + uses: ./.github/actions/set-up-test-project env: REDWOOD_DISABLE_TELEMETRY: 1 YARN_ENABLE_IMMUTABLE_INSTALLS: false @@ -193,107 +210,143 @@ jobs: - name: ๐ŸŽญ Install playwright dependencies run: npx playwright install --with-deps chromium - - name: Run `rw build` without prerender + - name: ๐Ÿง‘โ€๐Ÿ’ป Run dev smoke tests + working-directory: ./tasks/smoke-tests/dev + run: npx playwright test --project ${{ matrix.os == 'ubuntu-latest' && 'replay-chromium' || 'chromium' }} + env: + REDWOOD_PROJECT_PATH: '${{ steps.set-up-test-project.outputs.test-project-path }}' + REDWOOD_DISABLE_TELEMETRY: 1 + RECORD_REPLAY_METADATA_TEST_RUN_TITLE: ๐Ÿ”„ Smoke tests / ${{ matrix.os }} / node 18 latest + RECORD_REPLAY_TEST_METRICS: 1 + + - name: ๐Ÿ” Run auth smoke tests + working-directory: ./tasks/smoke-tests/auth + run: npx playwright test --project ${{ matrix.os == 'ubuntu-latest' && 'replay-chromium' || 'chromium' }} + env: + REDWOOD_PROJECT_PATH: ${{ steps.set-up-test-project.outputs.test-project-path }} + REDWOOD_DISABLE_TELEMETRY: 1 + RECORD_REPLAY_METADATA_TEST_RUN_TITLE: ๐Ÿ”„ Smoke tests / ${{ matrix.os }} / node 18 latest + RECORD_REPLAY_TEST_METRICS: 1 + + - name: Run `rw build --no-prerender` run: | yarn rw build --no-prerender - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run `rw prerender` run: | yarn rw prerender --verbose - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} + + - name: ๐Ÿ–ฅ๏ธ Run serve smoke tests + working-directory: tasks/smoke-tests/serve + run: npx playwright test --project ${{ matrix.os == 'ubuntu-latest' && 'replay-chromium' || 'chromium' }} + env: + REDWOOD_PROJECT_PATH: ${{ steps.set-up-test-project.outputs.test-project-path }} + REDWOOD_DISABLE_TELEMETRY: 1 + RECORD_REPLAY_METADATA_TEST_RUN_TITLE: ๐Ÿ”„ Smoke tests / ${{ matrix.os }} / node 18 latest + RECORD_REPLAY_TEST_METRICS: 1 + + - name: ๐Ÿ“„ Run prerender smoke tests + working-directory: tasks/smoke-tests/prerender + run: npx playwright test --project ${{ matrix.os == 'ubuntu-latest' && 'replay-chromium' || 'chromium' }} + env: + REDWOOD_PROJECT_PATH: ${{ steps.set-up-test-project.outputs.test-project-path }} + REDWOOD_DISABLE_TELEMETRY: 1 + RECORD_REPLAY_METADATA_TEST_RUN_TITLE: ๐Ÿ”„ Smoke tests / ${{ matrix.os }} / node 18 latest + RECORD_REPLAY_TEST_METRICS: 1 - - name: Run smoke tests on 'rw dev', 'rw serve', 'rw storybook' - working-directory: ./tasks/smoke-test - run: npx playwright test --project ${{ matrix.os == 'ubuntu-latest' && 'replay-chromium' || 'chromium' }} --reporter @replayio/playwright/reporter,line + - name: ๐Ÿ“• Run Storybook smoke tests + working-directory: tasks/smoke-tests/storybook + run: npx playwright test --project ${{ matrix.os == 'ubuntu-latest' && 'replay-chromium' || 'chromium' }} env: - PROJECT_PATH: ${{ steps.setup_test_project.outputs.test_project_path }} + REDWOOD_PROJECT_PATH: ${{ steps.set-up-test-project.outputs.test-project-path }} REDWOOD_DISABLE_TELEMETRY: 1 - RECORD_REPLAY_METADATA_TEST_RUN_TITLE: Smoke test / ${{ matrix.os }} / node 18 latest + RECORD_REPLAY_METADATA_TEST_RUN_TITLE: ๐Ÿ”„ Smoke tests / ${{ matrix.os }} / node 18 latest RECORD_REPLAY_TEST_METRICS: 1 - name: Run `rw info` run: | yarn rw info - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run `rw lint` run: | yarn rw lint ./api/src --fix - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run "rw test api" run: | yarn rw test api --no-watch - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run "rw test web" run: | yarn rw test web --no-watch - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run "rw check" run: | yarn rw check - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run "rw storybook" run: | yarn rw sb --smoke-test - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run "rw exec" run: | yarn rw g script testScript && yarn rw exec testScript - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run "prisma generate" run: | yarn rw prisma generate - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run "rw data-migrate" run: | yarn rw dataMigrate up - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run "data-migrate install" run: | yarn rw data-migrate install - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run "prisma migrate" run: | yarn rw prisma migrate dev --name ci-test - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run `rw deploy --help` run: yarn rw setup deploy --help && yarn rw deploy --help - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run `rw setup ui --help` run: yarn rw setup --help && yarn rw setup ui --help - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run "g page" run: | yarn rw g page ciTest - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run "g sdl" run: | yarn rw g sdl userExample - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Run "rw type-check" run: | yarn rw type-check - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} - name: Throw Error | Run `rw g sdl ` run: | yarn rw g sdl DoesNotExist - working-directory: ${{ steps.setup_test_project.outputs.test_project_path }} + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} continue-on-error: true - name: Upload Replays @@ -302,13 +355,13 @@ jobs: with: api-key: rwk_cZn4WLe8106j6tC5ygNQxDpxAwCLpFo5oLQftiRN7OP - smoke-test-docs: + smoke-tests-docs: needs: only-doc-changes if: needs.only-doc-changes.outputs.only-doc-changes == 'true' strategy: matrix: os: [ubuntu-latest, windows-latest] - name: ๐Ÿ‘€ Smoke test / ${{ matrix.os }} / node 18 latest + name: ๐Ÿ”„ Smoke test / ${{ matrix.os }} / node 18 latest runs-on: ${{ matrix.os }} steps: - run: echo "Only doc changes" diff --git a/package.json b/package.json index e6d6c2c6df00..86c9b7250de8 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,10 @@ "vscode-languageserver-types": "3.17.3" }, "devDependencies": { + "@actions/cache": "3.2.1", "@actions/core": "1.10.0", "@actions/exec": "1.1.1", + "@actions/glob": "0.4.0", "@babel/cli": "7.21.5", "@babel/core": "7.22.1", "@babel/generator": "7.22.3", diff --git a/tasks/smoke-test/playwright-fixtures/devServer.fixture.ts b/tasks/smoke-test/playwright-fixtures/devServer.fixture.ts deleted file mode 100644 index 2cc1a168c78b..000000000000 --- a/tasks/smoke-test/playwright-fixtures/devServer.fixture.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* eslint-disable no-empty-pattern */ -import { test as base } from '@playwright/test' -import chalk from 'chalk' -import execa, { ExecaChildProcess } from 'execa' -import isPortReachable from 'is-port-reachable' - -import { shutdownPort } from '@redwoodjs/internal/dist/dev' - -import { waitForServer } from '../util' - -// Declare worker fixtures. -export type DevServerFixtures = { - webServerPort: number - apiServerPort: number - server: any - webUrl: string -} - -// Note that we did not provide an test-scoped fixtures, so we pass {}. -const test = base.extend({ - webServerPort: [ - async ({}, use) => { - // "port" fixture uses a unique value of the worker process index. - await use(9000) - }, - { scope: 'worker' }, - ], - apiServerPort: [ - async ({}, use) => { - await use(9001) - }, - { scope: 'worker' }, - ], - webUrl: [ - async ({ webServerPort }, use) => { - await use(`localhost:${webServerPort}`) - }, - { scope: 'worker' }, - ], - - // "server" fixture starts automatically for every worker - we pass "auto" for that. - server: [ - async ({ webServerPort, apiServerPort }, use) => { - const projectPath = process.env.PROJECT_PATH - - if (!projectPath) { - throw new Error( - 'PROJECT_PATH env var not defined. Please build a test project, and re-run with PROJECT_PATH defined' - ) - } - - const serversUp = await Promise.all([ - isPortReachable(webServerPort, { - timeout: 5000, - }), - isPortReachable(apiServerPort, { - timeout: 5000, - }), - ]) - - if (serversUp.some((server) => server === true)) { - console.log('Found previous instances of dev server. Killing ๐Ÿช“!') - - shutdownPort(webServerPort) - shutdownPort(apiServerPort) - } - - let devServerHandler: ExecaChildProcess | null = null - - console.log(`Launching dev server at ${projectPath}`) - - // Don't wait for this to finish, because it doesn't - devServerHandler = execa(`yarn rw dev --no-generate --fwd="--no-open"`, { - cwd: projectPath, - shell: true, - detached: false, - env: { - WEB_DEV_PORT: webServerPort, - API_DEV_PORT: apiServerPort, - }, - cleanup: true, - }) - - // Pipe out logs so we can debug, when required - devServerHandler.stdout?.on('data', (data) => { - console.log( - '[devServer-fixture]', - Buffer.from(data, 'utf-8').toString() - ) - }) - devServerHandler.stderr?.on('data', (data) => { - console.log( - chalk.bgRed('[devServer-fixture]'), - Buffer.from(data, 'utf-8').toString() - ) - - throw new Error( - `Error starting server: ${Buffer.from(data, 'utf-8').toString()}` - ) - }) - - console.log('Waiting for dev servers.....') - await waitForServer(webServerPort) - await waitForServer(apiServerPort) - - console.log('Starting tests!') - - await use() - - // Make sure the dev server is killed after all tests are done. - // Re-using could be more efficient, but it seems to cause inconsistency - // It seems our Vite server gets killed after a run, but the API server does not - if (devServerHandler) { - console.log('Test complete. Killing dev servers ๐Ÿช“') - devServerHandler?.kill() - shutdownPort(webServerPort) - shutdownPort(apiServerPort) - } - }, - { scope: 'worker', auto: true }, - ], -}) - -export default test diff --git a/tasks/smoke-test/playwright-fixtures/rwServe.fixture.ts b/tasks/smoke-test/playwright-fixtures/rwServe.fixture.ts deleted file mode 100644 index b1bd3f71196f..000000000000 --- a/tasks/smoke-test/playwright-fixtures/rwServe.fixture.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* eslint-disable no-empty-pattern */ -import { test as base } from '@playwright/test' -import chalk from 'chalk' -import execa from 'execa' - -import { projectNeedsBuilding, waitForServer } from '../util' - -// Declare worker fixtures. -export type ServeFixture = { - port: number - server: any -} - -// Note that we did not provide an test-scoped fixtures, so we pass {}. -const test = base.extend({ - port: [ - async ({}, use, workerInfo) => { - // "port" fixture uses a unique value of the worker process index. - await use(8899 + workerInfo.workerIndex) - }, - { scope: 'worker' }, - ], - - // "server" fixture starts automatically for every worker - we pass "auto" for that. - server: [ - async ({ port }, use) => { - console.log('Starting rw server.....') - - const projectPath = process.env.PROJECT_PATH - - if (!projectPath) { - throw new Error( - 'PROJECT_PATH env var not defined. Please build a test project, and re-run with PROJECT_PATH defined' - ) - } - - console.log(`Running rw serve at ${projectPath}`) - - if (projectNeedsBuilding(projectPath)) { - console.log('Building project...') - // skip rw build if its already done - execa.sync(`yarn rw build`, { - cwd: projectPath, - shell: true, - stdio: 'inherit', - }) - } - - // Don't wait for this to finish, because it doesn't - const serverHandler = execa.command(`yarn rw serve -p ${port}`, { - cwd: projectPath, - shell: true, - detached: false, - }) - - if (!serverHandler) { - throw new Error('Could not start test server') - } - - // Pipe out logs so we can debug, when required - serverHandler.stdout?.on('data', (data) => { - console.log('[rw-serve-fixture]', Buffer.from(data, 'utf-8').toString()) - }) - serverHandler.stderr?.on('data', (data) => { - console.log( - chalk.bgRed('[rw-serve-fixture]'), - Buffer.from(data, 'utf-8').toString() - ) - }) - - console.log('Waiting for server.....') - await waitForServer(port, { host: '127.0.0.1' }) - - console.log('Starting tests!') - await use() - }, - { scope: 'worker', auto: true }, - ], -}) - -export default test diff --git a/tasks/smoke-test/playwright-fixtures/storybook.fixture.ts b/tasks/smoke-test/playwright-fixtures/storybook.fixture.ts deleted file mode 100644 index 76e2fdf8a313..000000000000 --- a/tasks/smoke-test/playwright-fixtures/storybook.fixture.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* eslint-disable no-empty-pattern */ -import { Transform } from 'stream' - -import { test as base } from '@playwright/test' -import execa from 'execa' -import isPortReachable from 'is-port-reachable' - -// Declare worker fixtures. -export type StorybookFixture = { - port: number - server: string -} - -// Note that we did not provide an test-scoped fixtures, so we pass {}. -const test = base.extend({ - port: [ - async ({}, use) => { - await use(7980) - }, - { scope: 'worker' }, - ], - - // "server" fixture starts automatically for every worker - we pass "auto" for that. - server: [ - async ({ port }, use) => { - console.log('Starting storybook server.....') - - const projectPath = process.env.PROJECT_PATH - - if (!projectPath) { - throw new Error( - 'PROJECT_PATH env var not defined. Please build a test project, and re-run with PROJECT_PATH defined' - ) - } - - console.log(`Running rw storybook at ${projectPath}`) - - const isServerAlreadyUp = await isPortReachable(port, { - timeout: 5000, - }) - - if (isServerAlreadyUp) { - console.log('Reusing existing SB server....') - console.log({ - port, - }) - } else { - // Don't wait for this to finish, because it doesn't - const serverHandler = execa( - `yarn rw storybook`, - ['--port', port, '--no-open', '--ci'], - { - cwd: projectPath, - shell: true, - cleanup: true, - detached: false, - } - ) - - let serverReadyPromiseHandle - - const waitForSbServer = new Promise((resolve, reject) => { - serverReadyPromiseHandle = { resolve, reject } - }) - - // Pipe out logs so we can debug, when required - serverHandler.stdout.on('data', (data) => { - const outputAsString = Buffer.from(data, 'utf-8').toString() - console.log('[rw-storybook-fixture]', outputAsString) - - if (outputAsString.includes(`http://localhost:${port}/`)) { - serverReadyPromiseHandle.resolve() - } - }) - - // Quick transform stream to prevent webpack output flooding the logs - const removeWebpackOutput = new Transform({ - transform(chunk, encoding, callback) { - callback(null, '') - }, - }) - - // @NOTE: For some reason we need to do this - // Because otherwise the server doesn't launch correctly - serverHandler.stdout.pipe(removeWebpackOutput).pipe(process.stdout) - serverHandler.stderr.pipe(removeWebpackOutput).pipe(process.stderr) - - console.log('Waiting for server.....') - await waitForSbServer - } - - console.log('Starting tests!') - await use(`Server ready at ${port}`) - }, - { scope: 'worker', auto: true }, - ], -}) - -export default test diff --git a/tasks/smoke-test/playwright.config.ts b/tasks/smoke-test/playwright.config.ts deleted file mode 100644 index 0da1743c17ac..000000000000 --- a/tasks/smoke-test/playwright.config.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { PlaywrightTestConfig, devices } from '@playwright/test' -import { devices as replayDevices } from '@replayio/playwright' - -// See https://playwright.dev/docs/test-configuration#global-configuration -const config: PlaywrightTestConfig = { - timeout: 90_000 * 3, - expect: { - timeout: 10 * 1000, - }, - workers: 1, // do not run things in parallel - - // Leaving this here to make debugging easier, by uncommenting - // use: { - // launchOptions: { - // slowMo: 500, - // headless: false, - // }, - // }, - projects: [ - { - name: 'replay-firefox', - use: { ...(replayDevices['Replay Firefox'] as any) }, - }, - { - name: 'replay-chromium', - use: { ...(replayDevices['Replay Chromium'] as any) }, - }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - { - name: 'chromium', - use: { ...devices['Desktop Chromium'] }, - }, - ], -} - -export default config diff --git a/tasks/smoke-test/tests/authChecks.spec.ts b/tasks/smoke-test/tests/authChecks.spec.ts deleted file mode 100644 index 6863c4158099..000000000000 --- a/tasks/smoke-test/tests/authChecks.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - expect, - PlaywrightTestArgs, - PlaywrightWorkerArgs, -} from '@playwright/test' - -import devServerTest, { - DevServerFixtures, -} from '../playwright-fixtures/devServer.fixture' - -import { loginAsTestUser, signUpTestUser } from './common' - -// Signs up a user before these tests - -devServerTest.beforeAll(async ({ browser }: PlaywrightWorkerArgs) => { - const page = await browser.newPage() - - await signUpTestUser({ - // @NOTE we can't access webUrl in beforeAll, so hardcoded - // But we can switch to beforeEach if required - webUrl: 'http://localhost:9000', - page, - }) - - await page.close() -}) - -devServerTest( - 'useAuth hook, auth redirects checks', - async ({ page, webUrl }: PlaywrightTestArgs & DevServerFixtures) => { - await page.goto(`${webUrl}/profile`) - - // To check redirects to the login page - await expect(page).toHaveURL(`http://${webUrl}/login?redirectTo=/profile`) - - await loginAsTestUser({ page, webUrl }) - - await page.goto(`${webUrl}/profile`) - - const usernameRow = await page.waitForSelector('*css=tr >> text=EMAIL') - await expect(await usernameRow.innerHTML()).toBe( - 'EMAILtestuser@bazinga.com' - ) - - const isAuthenticatedRow = await page.waitForSelector( - '*css=tr >> text=isAuthenticated' - ) - await expect(await isAuthenticatedRow.innerHTML()).toBe( - 'isAuthenticatedtrue' - ) - - const isAdminRow = await page.waitForSelector('*css=tr >> text=Is Admin') - await expect(await isAdminRow.innerHTML()).toBe( - 'Is Adminfalse' - ) - - // Log Out - await page.goto(`${webUrl}/`) - await page.click('text=Log Out') - await expect(await page.locator('text=Login')).toBeTruthy() - } -) - -devServerTest( - 'requireAuth graphql checks', - async ({ page, webUrl }: DevServerFixtures & PlaywrightTestArgs) => { - // Create posts - await createNewPost({ page, webUrl }) - - await expect( - page - .locator('.rw-form-error-title') - .locator("text=You don't have permission to do that") - ).toBeTruthy() - - await page.goto(`${webUrl}/`) - - await expect( - await page - .locator('article:has-text("Hello world! Soft kittens are the best.")') - .count() - ).toBe(0) - - await loginAsTestUser({ - webUrl, - page, - }) - - await createNewPost({ page, webUrl }) - - await page.goto(`${webUrl}/`) - await expect( - await page - .locator('article:has-text("Hello world! Soft kittens are the best.")') - .first() - ).not.toBeEmpty() - } -) - -async function createNewPost({ webUrl, page }) { - await page.goto(`${webUrl}/posts/new`) - - await page.locator('input[name="title"]').click() - await page - .locator('input[name="title"]') - .fill('Hello world! Soft kittens are the best.') - await page.locator('input[name="title"]').press('Tab') - await page.locator('input[name="body"]').fill('Bazinga, bazinga, bazinga') - await page.locator('input[name="authorId"]').fill('2') - - const permissionError = page - .locator('.rw-form-error-title') - .locator(`text=You don't have permission to do that`) - - // Either wait for success and redirect - // Or get the error - await Promise.all([ - Promise.race([ - page.waitForURL('**/'), - permissionError.waitFor({ timeout: 5000 }), - ]), - await page.click('text=SAVE'), - ]) -} diff --git a/tasks/smoke-test/tests/prerender.spec.ts b/tasks/smoke-test/tests/prerender.spec.ts deleted file mode 100644 index 799536c8734c..000000000000 --- a/tasks/smoke-test/tests/prerender.spec.ts +++ /dev/null @@ -1,347 +0,0 @@ -import fs from 'fs' -import path from 'path' - -import { - BrowserContext, - expect, - PlaywrightTestArgs, - PlaywrightWorkerArgs, -} from '@playwright/test' -import execa from 'execa' - -import rwServeTest from '../playwright-fixtures/rwServe.fixture' -import type { ServeFixture } from '../playwright-fixtures/rwServe.fixture' - -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping -function escapeRegExp(string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string -} - -let noJsBrowser: BrowserContext -rwServeTest.beforeAll(async ({ browser }: PlaywrightWorkerArgs) => { - noJsBrowser = await browser.newContext({ - javaScriptEnabled: false, - }) -}) - -rwServeTest( - 'Check that homepage is prerendered', - async ({ port }: ServeFixture & PlaywrightTestArgs) => { - const pageWithoutJs = await noJsBrowser.newPage() - await pageWithoutJs.goto(`http://localhost:${port}/`) - - const cellSuccessState = await pageWithoutJs.locator('main').innerHTML() - expect(cellSuccessState).toMatch(/Welcome to the blog!/) - expect(cellSuccessState).toMatch(/A little more about me/) - expect(cellSuccessState).toMatch(/What is the meaning of life\?/) - - const navTitle = await pageWithoutJs.locator('header >> h1').innerText() - expect(navTitle).toBe('Redwood Blog') - - const navLinks = await pageWithoutJs.locator('nav >> ul').innerText() - expect(navLinks.split('\n')).toEqual([ - 'About', - 'Contact Us', - 'Admin', - 'Log In', - ]) - - pageWithoutJs.close() - } -) - -rwServeTest( - 'Check that rehydration works for page not wrapped in Set', - async ({ port, page }: ServeFixture & PlaywrightTestArgs) => { - const errors: string[] = [] - - page.on('pageerror', (err) => { - errors.push(err.message) - }) - - page.on('console', (message) => { - if (message.type() === 'error') { - errors.push(message.text()) - } - }) - - await page.goto(`http://localhost:${port}/double`) - - // Wait for page to have been rehydrated before getting page content. - // We know the page has been rehydrated when it sends an auth request - await page.waitForResponse((response) => - response.url().includes('/.redwood/functions/auth') - ) - - await page.locator('h1').first().waitFor() - const headerCount = await page - .locator('h1', { hasText: 'DoublePage' }) - .count() - expect(headerCount).toEqual(1) - - const bodyText = await page.locator('body').innerText() - expect(bodyText.match(/#7757/g)).toHaveLength(1) - - const title = await page.locator('title').innerText() - expect(title).toBe('Double | Redwood App') - - expect(errors).toMatchObject([]) - - page.close() - } -) - -rwServeTest( - 'Check that rehydration works for page with Cell in Set', - async ({ port, page, context }: ServeFixture & PlaywrightTestArgs) => { - const errors: string[] = [] - - page.on('pageerror', (err) => { - errors.push(err.message) - }) - - page.on('console', (message) => { - if (message.type() === 'error') { - errors.push(message.text()) - } - }) - - await page.goto(`http://localhost:${port}/`) - - // Wait for page to have been rehydrated and cells have fetched their data - // before getting page content. - // We know cells have started fetching data when we see graphql requests - await page.waitForResponse((response) => - response.url().includes('/.redwood/functions/graphql') - ) - - await page.locator('h2').first().waitFor() - const mainText = await page.locator('main').innerText() - expect(mainText.match(/Welcome to the blog!/g)).toHaveLength(1) - expect(mainText.match(/A little more about me/g)).toHaveLength(1) - - // Something strange is going on here. Sometimes React generates errors, - // sometimes it doesn't. - // The problem is we have a Cell with prerendered content. Then when the - // page is rehydrated JS kicks in and the cell fetches data from the - // server. While it's getting the data "Loading..." is shown. This doesn't - // match up with the content that was prerendered, so React complains. - // We have a around the Cell, so React will stop at the - // suspense boundary and do full CSR of the cell content (instead of - // throwing away the entire page and do a CSR of the whole thing as it - // would have done without the ). - // Until we fully understand why we only get the errors sometimes we can't - // have this `expect` enabled - // expect(errors).toMatchObject([]) - - page.close() - } -) - -rwServeTest( - 'Check that rehydration works for page with code split chunks', - async ({ port, page }: ServeFixture & PlaywrightTestArgs) => { - const errors: string[] = [] - - page.on('pageerror', (err) => { - errors.push(err.message) - }) - - page.on('console', (message) => { - if (message.type() === 'error') { - errors.push(message.text()) - } - }) - - // This page uses Redwood Forms, and so does /posts/new. Webpack splits rw - // forms out into a separate chunk. We need to make sure our prerender - // code can handle that - await page.goto(`http://localhost:${port}/contacts/new`) - - // Wait for page to have been rehydrated before getting page content. - // We know the page has been rehydrated when it sends an auth request - await page.waitForResponse((response) => - response.url().includes('/.redwood/functions/auth') - ) - - await expect(page.getByLabel('Name')).toBeVisible() - await expect(page.getByLabel('Email')).toBeVisible() - await expect(page.getByLabel('Message')).toBeVisible() - - expect(errors).toMatchObject([]) - - page.close() - } -) - -rwServeTest( - 'Check that a specific blog post is prerendered', - async ({ port }: ServeFixture & PlaywrightTestArgs) => { - const pageWithoutJs = await noJsBrowser.newPage() - - // It's non-deterministic what id the posts get, so we have to first find - // the url of a given post, and then navigate to that - - await pageWithoutJs.goto(`http://localhost:${port}/`) - const meaningOfLifeHref = await pageWithoutJs - .locator('a:has-text("What is the meaning of life?")') - .getAttribute('href') - - await pageWithoutJs.goto(`http://localhost:${port}${meaningOfLifeHref}`) - - const mainContent = await pageWithoutJs.locator('main').innerHTML() - expect(mainContent).toMatch(/What is the meaning of life\?/) - // Test that nested cell content is also rendered - expect(mainContent).toMatch(/user\.two@example\.com/) - expect(mainContent).not.toMatch(/Welcome to the blog!/) - expect(mainContent).not.toMatch(/A little more about me/) - - const navTitle = await pageWithoutJs.locator('header >> h1').innerText() - expect(navTitle).toBe('Redwood Blog') - - const navLinks = await pageWithoutJs.locator('nav >> ul').innerText() - expect(navLinks.split('\n')).toEqual([ - 'About', - 'Contact Us', - 'Admin', - 'Log In', - ]) - - pageWithoutJs.close() - } -) - -rwServeTest( - 'Check that meta-tags are rendering the correct dynamic data', - async ({ port }: ServeFixture & PlaywrightTestArgs) => { - const pageWithoutJs = await noJsBrowser.newPage() - - await pageWithoutJs.goto(`http://localhost:${port}/blog-post/1`) - - const metaDescription = await pageWithoutJs.locator( - 'meta[name="description"]' - ) - - const ogDescription = await pageWithoutJs.locator( - 'meta[property="og:description"]' - ) - await expect(metaDescription).toHaveAttribute('content', 'Description 1') - await expect(ogDescription).toHaveAttribute('content', 'Description 1') - - const title = await pageWithoutJs.locator('title').innerHTML() - await expect(title).toBe('Post 1 | Redwood App') - - const ogTitle = await pageWithoutJs.locator('meta[property="og:title"]') - await expect(ogTitle).toHaveAttribute('content', 'Post 1') - } -) - -rwServeTest( - 'Check that you can navigate from home page to specific blog post', - async ({ port }: ServeFixture & PlaywrightTestArgs) => { - const pageWithoutJs = await noJsBrowser.newPage() - await pageWithoutJs.goto(`http://localhost:${port}`) - - let mainContent = await pageWithoutJs.locator('main').innerHTML() - expect(mainContent).toMatch(/Welcome to the blog!/) - expect(mainContent).toMatch(/A little more about me/) - expect(mainContent).toMatch(/What is the meaning of life\?/) - - await pageWithoutJs.goto(`http://localhost:${port}/`) - const aboutMeAnchor = await pageWithoutJs.locator( - 'a:has-text("A little more about me")' - ) - - await aboutMeAnchor.click() - - const aboutMeAnchorHref = (await aboutMeAnchor.getAttribute('href')) || '' - expect(aboutMeAnchorHref).not.toEqual('') - - mainContent = await pageWithoutJs.locator('main').innerHTML() - expect(mainContent).toMatch(/A little more about me/) - expect(mainContent).not.toMatch(/Welcome to the blog!/) - expect(mainContent).not.toMatch(/What is the meaning of life\?/) - expect(pageWithoutJs.url()).toMatch( - new RegExp(escapeRegExp(aboutMeAnchorHref)) - ) - - pageWithoutJs.close() - } -) - -rwServeTest( - 'Check that about is prerendered', - async ({ port }: ServeFixture & PlaywrightTestArgs) => { - const pageWithoutJs = await noJsBrowser.newPage() - await pageWithoutJs.goto(`http://localhost:${port}/about`) - - const aboutPageContent = await pageWithoutJs.locator('main').innerText() - expect(aboutPageContent).toBe( - 'This site was created to demonstrate my mastery of Redwood: Look on my works, ye mighty, and despair!' - ) - pageWithoutJs.close() - } -) - -// We don't really need a server running here. So we could just use `test()` -// straight from playwright. But we do need to have the project built. And -// `rwServeTest()` does that. If we try to add project building to this test as -// well we will build twice, and we don't want that. Hence we use rwServeTest. -rwServeTest('prerender with broken gql query', async () => { - const projectPath = process.env.PROJECT_PATH || '' - - const cellBasePath = path.join( - projectPath, - 'web', - 'src', - 'components', - 'BlogPostsCell' - ) - - const cellPathJs = path.join(cellBasePath, 'BlogPostsCell.js') - const cellPathTs = path.join(cellBasePath, 'BlogPostsCell.tsx') - const cellPath = fs.existsSync(cellPathTs) ? cellPathTs : cellPathJs - - const blogPostsCell = fs.readFileSync(cellPath, 'utf-8') - fs.writeFileSync(cellPath, blogPostsCell.replace('createdAt', 'timestamp')) - - try { - await execa(`yarn rw prerender`, { - cwd: projectPath, - shell: true, - }) - } catch (e) { - expect(e.message).toMatch( - /GQL error: Cannot query field "timestamp" on type "Post"\./ - ) - } - - // Restore cell - fs.writeFileSync(cellPath, blogPostsCell) - - // Make sure to restore any potentially broken/missing prerendered pages - await execa(`yarn rw prerender`, { - cwd: projectPath, - shell: true, - }) -}) - -rwServeTest( - 'Waterfall prerendering (nested cells)', - async ({ port }: ServeFixture & PlaywrightTestArgs) => { - const pageWithoutJs = await noJsBrowser.newPage() - - // It's non-deterministic what id the posts get, so we're pretty generic - // with what we're matching in this test case - - await pageWithoutJs.goto(`http://localhost:${port}/waterfall/2`) - - const mainContent = await pageWithoutJs.locator('main').innerHTML() - expect(mainContent).toMatch(/[\w\s?!]+<\/h2><\/header>/) - // Test that nested cell content is also rendered - expect(mainContent).toMatch(/class="author-cell"/) - expect(mainContent).toMatch(/user.(one|two)@example.com/) - - pageWithoutJs.close() - } -) diff --git a/tasks/smoke-test/tests/rbacChecks.spec.ts b/tasks/smoke-test/tests/rbacChecks.spec.ts deleted file mode 100644 index 7320770b5652..000000000000 --- a/tasks/smoke-test/tests/rbacChecks.spec.ts +++ /dev/null @@ -1,221 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' - -import { - expect, - Page, - PlaywrightTestArgs, - PlaywrightWorkerArgs, -} from '@playwright/test' -import execa from 'execa' - -import devServerTest, { - DevServerFixtures, -} from '../playwright-fixtures/devServer.fixture' - -import { loginAsTestUser, signUpTestUser } from './common' - -// This is a special test that does the following -// Signup a user (admin@bazinga.com), because salt/secrets won't match, we need to do this -// Then makes them admin with an rw exec script -// Then checks if they have admin privileges -const adminEmail = 'admin@bazinga.com' -const password = 'test123' - -let messageDate -devServerTest.beforeAll(async ({ browser }: PlaywrightWorkerArgs) => { - // @NOTE we can't access webUrl in beforeAll, so hardcoded - // But we can switch to beforeEach if required - const webUrl = 'http://localhost:9000' - - const adminSignupPage = await browser.newPage() - const incognitoPage = await browser.newPage() - const regularUserSignupPage = await browser.newPage() - - await Promise.all([ - signUpTestUser({ - // @NOTE we can't access webUrl in beforeAll, so hardcoded - // But we can switch to beforeEach if required - webUrl, - page: adminSignupPage, - email: adminEmail, - password, - fullName: 'Admin User', - }), - // Signup non-admin user - signUpTestUser({ - webUrl, - page: regularUserSignupPage, - }), - fillOutContactFormAsAnonymousUser({ - page: incognitoPage, - webUrl, - messageDate, - }), - ]) - - await Promise.all([ - adminSignupPage.close(), - regularUserSignupPage.close(), - incognitoPage.close(), - ]) -}) - -devServerTest( - 'RBAC: Should not be able to delete contact as non-admin user', - async ({ webUrl, page }: DevServerFixtures & PlaywrightTestArgs) => { - // Login as non-admin user - await loginAsTestUser({ - webUrl, - page, - }) - - // Go to http://localhost:8910/contacts - await page.goto(`${webUrl}/contacts`) - - page.once('dialog', (dialog) => { - console.log(`Dialog message: ${dialog.message()}`) - dialog.accept().catch(() => { - console.error('Failed to accept dialog') - }) - }) - - await page.locator('text=Delete').first().click() - - await expect( - page - .locator('.rw-scaffold') - .locator("text=You don't have permission to do that") - ).toBeTruthy() - - // @NOTE we do this because the scaffold content is actually on the page, - // This is the only way we validate if its actually showing visually - await expect( - page.locator('.rw-scaffold').locator('text=Contact deleted') - ).toBeHidden() - - await expect( - await page.locator('text=charlie@chimichanga.com').count() - ).toBeGreaterThan(0) - } -) - -devServerTest( - 'RBAC: Admin user should be able to delete contacts', - async ({ webUrl, page }: DevServerFixtures & PlaywrightTestArgs) => { - fs.writeFileSync( - path.join(process.env.PROJECT_PATH, 'scripts/makeAdmin.ts'), - ` - import { db } from 'api/src/lib/db' - -export default async ({ args }) => { -await db.user.update({ -where: { - email: args.email, -}, -data: { - roles: 'ADMIN', -}, -}) - -console.log(await db.user.findMany()) -}` - ) - - console.log(`Giving ${adminEmail} ADMIN role....`) - await execa(`yarn rw exec makeAdmin --email ${adminEmail}`, { - cwd: process.env.PROJECT_PATH, - stdio: 'inherit', - shell: true, - }) - - await loginAsTestUser({ - webUrl, - page, - email: adminEmail, - password, - }) - - // Go to http://localhost:8910/contacts - await page.goto(`${webUrl}/contacts`) - - // This makes the test less flaky when running locally against the same - // test project multiple times - const contactCountBefore = await waitForContact(page) - - page.once('dialog', (dialog) => { - console.log(`Dialog message: ${dialog.message()}`) - dialog.accept().catch(() => { - console.error('Failed to accept dialog') - }) - }) - - await page.locator('text=Delete').first().click() - - await expect( - page.locator('.rw-scaffold').locator('text=Contact deleted') - ).toBeVisible() - - await expect( - await page.locator('text=charlie@chimichanga.com').count() - ).toBe(contactCountBefore - 1) - } -) - -async function fillOutContactFormAsAnonymousUser({ - page, - webUrl, - messageDate, -}: { - page: PlaywrightTestArgs['page'] - webUrl: string - messageDate: string -}) { - await page.goto(`${webUrl}/contact`) - // Click input[name="name"] - await page.locator('input[name="name"]').click() - // Fill input[name="name"] - await page.locator('input[name="name"]').fill('Charlie Chimichanga') - // Click input[name="email"] - await page.locator('input[name="email"]').click() - // Fill input[name="email"] - await page.locator('input[name="email"]').fill('charlie@chimichanga.com') - // Click textarea[name="message"] - await page.locator('textarea[name="message"]').click() - // Fill textarea[name="message"] - await page - .locator('textarea[name="message"]') - .fill(`Hello, I love Mexican food. What about you? - ${messageDate}`) - // Click text=Save - const successMessage = page.locator(`text=Thank you for your submission!`) - - await Promise.all([ - successMessage.waitFor({ timeout: 5000 }), - page.locator('text=Save').click(), - ]) -} - -function waitForContact(page: Page) { - const MAX_INTERVALS = 10 - - return new Promise((resolve) => { - let intervals = 0 - const watchInterval = setInterval(async () => { - console.log('Waiting for Charlie...') - const contactCount = await page - .locator('text=charlie@chimichanga.com') - .count() - - if (contactCount > 0) { - clearInterval(watchInterval) - resolve(contactCount) - } - - if (intervals > MAX_INTERVALS) { - expect(contactCount).toBeGreaterThan(0) - } - - intervals++ - }, 100) - }) -} diff --git a/tasks/smoke-test/tests/rwDev.spec.ts b/tasks/smoke-test/tests/rwDev.spec.ts deleted file mode 100644 index 1bf784a946b4..000000000000 --- a/tasks/smoke-test/tests/rwDev.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -import devServerTest from '../playwright-fixtures/devServer.fixture' - -import { smokeTest } from './common' - -devServerTest('Smoke test with dev server', smokeTest) diff --git a/tasks/smoke-test/tests/rwServe.spec.ts b/tasks/smoke-test/tests/rwServe.spec.ts deleted file mode 100644 index 716bfeb0bf0b..000000000000 --- a/tasks/smoke-test/tests/rwServe.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import rwServeTest from '../playwright-fixtures/rwServe.fixture' - -import { smokeTest } from './common' - -rwServeTest('Smoke test with rw serve', ({ port, page }) => - smokeTest({ webServerPort: port, page }) -) diff --git a/tasks/smoke-test/tests/storybook.spec.ts b/tasks/smoke-test/tests/storybook.spec.ts deleted file mode 100644 index e46f835e58e6..000000000000 --- a/tasks/smoke-test/tests/storybook.spec.ts +++ /dev/null @@ -1,187 +0,0 @@ -import fs from 'fs' -import path from 'path' - -import { PlaywrightTestArgs, expect } from '@playwright/test' - -import storybookTest, { - StorybookFixture, -} from '../playwright-fixtures/storybook.fixture' - -storybookTest( - 'Loads Cell Stories', - async ({ port, page, server }: PlaywrightTestArgs & StorybookFixture) => { - // We do this to make sure playwright doesn't bring the server down - console.log(server) - const STORYBOOK_URL = `http://localhost:${port}/` - - await page.goto(STORYBOOK_URL) - - // Click text=BlogPostCell - await page.locator('text=/\\bBlogPostCell\\b/').click() - - await expect(page).toHaveURL( - `http://localhost:${port}/?path=/story/cells-blogpostcell--loading` - ) - - await expect( - page.frameLocator('#storybook-preview-iframe').locator('body') - ).toContainText('Loading...') - - // Click text=Failure - await page.locator('text=Failure').click() - await expect(page).toHaveURL( - `http://localhost:${port}/?path=/story/cells-blogpostcell--failure` - ) - - await expect( - page.frameLocator('#storybook-preview-iframe').locator('body') - ).toContainText('Error: Oh no') - - // Check Loading - await page.locator('text=Empty').click() - await expect(page).toHaveURL( - `http://localhost:${port}/?path=/story/cells-blogpostcell--empty` - ) - - await expect( - page.frameLocator('#storybook-preview-iframe').locator('body') - ).toContainText('Empty') - - // Check Success - // And make sure MSW Cell mocks are loaded as expected - await page.locator('text=Success').click() - await expect(page).toHaveURL( - `http://localhost:${port}/?path=/story/cells-blogpostcell--success` - ) - - await expect( - page.frameLocator('#storybook-preview-iframe').locator('body') - ).toContainText('Mocked title') - - await expect( - page.frameLocator('#storybook-preview-iframe').locator('body') - ).toContainText('Mocked body') - } -) - -storybookTest( - 'Loads Cell mocks when Cell is nested in another story', - async ({ port, page, server }: PlaywrightTestArgs & StorybookFixture) => { - // We do this to make sure playwright doesn't bring the server down - console.log(server) - const STORYBOOK_URL = `http://localhost:${port}/` - - await page.goto(STORYBOOK_URL) - - // Click text=BlogPostCell - await page.locator('text=BlogPostPage').click() - - // Click text=Empty - await expect(page).toHaveURL( - `http://localhost:${port}/?path=/story/pages-blogpostpage--generated` - ) - - await expect( - page.frameLocator('#storybook-preview-iframe').locator('body') - ).toContainText('Mocked title') - - await expect( - page.frameLocator('#storybook-preview-iframe').locator('body') - ).toContainText('Mocked body') - } -) - -storybookTest( - 'Mocks current user, and updates UI while dev server is running', - async ({ port, page, server }: PlaywrightTestArgs & StorybookFixture) => { - const profileStoryPath = path.join( - process.env.PROJECT_PATH, - 'web/src/pages/ProfilePage/ProfilePage.stories.tsx' - ) - - // Modify profile page stories to mockCurrentUser - const profilePageStoryContent = fs.readFileSync(profileStoryPath, 'utf-8') - - if (!profilePageStoryContent.includes('mockCurrentUser')) { - const contentWithMockCurrentUser = profilePageStoryContent.replace( - 'export const generated = () => {', - `export const generated = () => { - mockCurrentUser({ - email: 'ba@zinga.com', - id: 55, - roles: 'ADMIN', - }) - ` - ) - - fs.writeFileSync(profileStoryPath, contentWithMockCurrentUser) - } - - // We do this to make sure playwright doesn't bring the server down - console.log(server) - const STORYBOOK_URL = `http://localhost:${port}/` - - await page.goto(STORYBOOK_URL) - - await Promise.all([ - page.waitForLoadState(), - page.waitForSelector('text=ProfilePage'), - ]) - - await page.locator('text=ProfilePage').click() - - try { - await page - .frameLocator('#storybook-preview-iframe') - .locator('css=h1 >> text=Profile') - .waitFor({ timeout: 5_000 }) - } catch { - await page.reload() - } - - const usernameRow = await page - .frameLocator('#storybook-preview-iframe') - .locator('*css=tr >> text=EMAIL') - await expect(await usernameRow.innerHTML()).toBe( - 'EMAILba@zinga.com' - ) - - const isAuthenticatedRow = await page - .frameLocator('#storybook-preview-iframe') - .locator('*css=tr >> text=isAuthenticated') - await expect(await isAuthenticatedRow.innerHTML()).toBe( - 'isAuthenticatedtrue' - ) - - const isAdminRow = await page - .frameLocator('#storybook-preview-iframe') - .locator('*css=tr >> text=Is Admin') - await expect(await isAdminRow.innerHTML()).toBe( - 'Is Admintrue' - ) - } -) - -storybookTest( - 'Loads MDX Stories', - async ({ port, page, server }: PlaywrightTestArgs & StorybookFixture) => { - // We do this to make sure playwright doesn't bring the server down - console.log(server) - const STORYBOOK_URL = `http://localhost:${port}/` - - await page.goto(STORYBOOK_URL) - - // Click Redwood link in left nav - await page.locator('id=redwood--docs').click() - - await expect(page).toHaveURL( - `http://localhost:${port}/?path=/docs/redwood--docs` - ) - - await expect( - page.frameLocator('#storybook-preview-iframe').locator('body') - ).toContainText( - 'Redwood is an opinionated, full-stack, JavaScript/TypeScript web application framework designed to keep you moving fast as your app grows from side project to startup.' - ) - } -) diff --git a/tasks/smoke-test/util.ts b/tasks/smoke-test/util.ts deleted file mode 100644 index ba66c25c1d8a..000000000000 --- a/tasks/smoke-test/util.ts +++ /dev/null @@ -1,35 +0,0 @@ -import path from 'node:path' - -import { pathExistsSync } from 'fs-extra' -import isPortReachable from 'is-port-reachable' - -interface Options { - interval?: number - host?: string -} - -// On Node.js 18, when using `yarn rw serve`, we have to pass '127.0.0.1' -// instead of 'localhost'. See https://github.com/nodejs/node/issues/40537 -export function waitForServer(port, options?: Options) { - const interval = options?.interval || 1_000 - const host = options?.host || 'localhost' - - return new Promise((resolve) => { - const watchInterval = setInterval(async () => { - console.log(`Waiting for server at localhost:${port}....`) - const isServerUp = await isPortReachable(port, { host }) - if (isServerUp) { - clearInterval(watchInterval) - resolve(true) - } - }, interval) - }) -} - -export const projectNeedsBuilding = ( - projectPath: string = process.env.PROJECT_PATH || '' -) => { - const webDist = path.join(projectPath, 'web/dist') - const apiDist = path.join(projectPath, 'api/dist') - return !pathExistsSync(webDist) || !pathExistsSync(apiDist) -} diff --git a/tasks/smoke-tests/auth/playwright.config.ts b/tasks/smoke-tests/auth/playwright.config.ts new file mode 100644 index 000000000000..08ec33704854 --- /dev/null +++ b/tasks/smoke-tests/auth/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test' + +import { basePlaywrightConfig } from '../basePlaywright.config' + +// See https://playwright.dev/docs/test-configuration#global-configuration +export default defineConfig({ + ...basePlaywrightConfig, + + use: { + baseURL: 'http://localhost:8910', + }, + + // Run your local dev server before starting the tests + webServer: { + command: 'yarn redwood dev --no-generate --fwd="--no-open"', + cwd: process.env.REDWOOD_PROJECT_PATH, + url: 'http://localhost:8911/graphql?query={redwood{version}}', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, +}) diff --git a/tasks/smoke-tests/auth/tests/authChecks.spec.ts b/tasks/smoke-tests/auth/tests/authChecks.spec.ts new file mode 100644 index 000000000000..d5b8fa1c5632 --- /dev/null +++ b/tasks/smoke-tests/auth/tests/authChecks.spec.ts @@ -0,0 +1,106 @@ +import { test, expect } from '@playwright/test' + +import { loginAsTestUser, signUpTestUser } from '../../common' + +// Signs up a user before these tests + +test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + + await signUpTestUser({ page }) + + await page.close() +}) + +test('useAuth hook, auth redirects checks', async ({ page }) => { + await page.goto('/profile') + + // To check redirects to the login page + await expect(page).toHaveURL( + `http://localhost:8910/login?redirectTo=/profile` + ) + + await loginAsTestUser({ page }) + + await page.goto('/profile') + + const usernameRow = await page.waitForSelector('*css=tr >> text=EMAIL') + await expect(await usernameRow.innerHTML()).toBe( + 'EMAILtestuser@bazinga.com' + ) + + const isAuthenticatedRow = await page.waitForSelector( + '*css=tr >> text=isAuthenticated' + ) + await expect(await isAuthenticatedRow.innerHTML()).toBe( + 'isAuthenticatedtrue' + ) + + const isAdminRow = await page.waitForSelector('*css=tr >> text=Is Admin') + await expect(await isAdminRow.innerHTML()).toBe( + 'Is Adminfalse' + ) + + // Log Out + await page.goto('/') + await page.click('text=Log Out') + await expect(await page.locator('text=Login')).toBeTruthy() +}) + +test('requireAuth graphql checks', async ({ page }) => { + // Create posts + await createNewPost({ page }) + + await expect( + page + .locator('.rw-form-error-title') + .locator("text=You don't have permission to do that") + ).toBeTruthy() + + await page.goto('/') + + await expect( + await page + .locator('article:has-text("Hello world! Soft kittens are the best.")') + .count() + ).toBe(0) + + await loginAsTestUser({ + page, + }) + + await createNewPost({ page }) + + await page.goto('/') + await expect( + await page + .locator('article:has-text("Hello world! Soft kittens are the best.")') + .first() + ).not.toBeEmpty() +}) + +async function createNewPost({ page }) { + await page.goto('/posts/new') + + await page.locator('input[name="title"]').click() + await page + .locator('input[name="title"]') + .fill('Hello world! Soft kittens are the best.') + await page.locator('input[name="title"]').press('Tab') + await page.locator('input[name="body"]').fill('Bazinga, bazinga, bazinga') + await page.locator('input[name="authorId"]').fill('2') + + const permissionError = page + .locator('.rw-form-error-title') + .locator(`text=You don't have permission to do that`) + + // Either wait for success and redirect + // Or get the error + await Promise.all([ + Promise.race([ + page.waitForURL('**/'), + permissionError.waitFor({ timeout: 5000 }), + ]), + await page.click('text=SAVE'), + ]) +} diff --git a/tasks/smoke-tests/auth/tests/rbacChecks.spec.ts b/tasks/smoke-tests/auth/tests/rbacChecks.spec.ts new file mode 100644 index 000000000000..d2ff42c5d8f6 --- /dev/null +++ b/tasks/smoke-tests/auth/tests/rbacChecks.spec.ts @@ -0,0 +1,199 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { test, expect } from '@playwright/test' +import type { PlaywrightTestArgs, Page } from '@playwright/test' +import execa from 'execa' + +import { loginAsTestUser, signUpTestUser } from '../../common' + +// This is a special test that does the following +// Signup a user (admin@bazinga.com), because salt/secrets won't match, we need to do this +// Then makes them admin with an rw exec script +// Then checks if they have admin privileges +const adminEmail = 'admin@bazinga.com' +const password = 'test123' + +let messageDate + +test.beforeAll(async ({ browser }) => { + const adminSignupPage = await browser.newPage() + const incognitoPage = await browser.newPage() + const regularUserSignupPage = await browser.newPage() + + await Promise.all([ + signUpTestUser({ + page: adminSignupPage, + email: adminEmail, + password, + fullName: 'Admin User', + }), + // Signup non-admin user + signUpTestUser({ + page: regularUserSignupPage, + }), + fillOutContactFormAsAnonymousUser({ + page: incognitoPage, + messageDate, + }), + ]) + + await Promise.all([ + adminSignupPage.close(), + regularUserSignupPage.close(), + incognitoPage.close(), + ]) +}) + +test('RBAC: Should not be able to delete contact as non-admin user', async ({ + page, +}) => { + // Login as non-admin user + await loginAsTestUser({ + page, + }) + + // Go to http://localhost:8910/contacts + await page.goto('/contacts') + + page.once('dialog', (dialog) => { + console.log(`Dialog message: ${dialog.message()}`) + dialog.accept().catch(() => { + console.error('Failed to accept dialog') + }) + }) + + await page.locator('text=Delete').first().click() + + await expect( + page + .locator('.rw-scaffold') + .locator("text=You don't have permission to do that") + ).toBeTruthy() + + // @NOTE we do this because the scaffold content is actually on the page, + // This is the only way we validate if its actually showing visually + await expect( + page.locator('.rw-scaffold').locator('text=Contact deleted') + ).toBeHidden() + + await expect( + await page.locator('text=charlie@chimichanga.com').count() + ).toBeGreaterThan(0) +}) + +test('RBAC: Admin user should be able to delete contacts', async ({ page }) => { + fs.writeFileSync( + path.join( + process.env.REDWOOD_PROJECT_PATH as string, + 'scripts/makeAdmin.ts' + ), + `\ +import { db } from 'api/src/lib/db' + +export default async ({ args }) => { + await db.user.update({ + where: { + email: args.email, + }, + data: { + roles: 'ADMIN', + }, + }) + + console.log(await db.user.findMany()) +}` + ) + + console.log(`Giving ${adminEmail} ADMIN role....`) + await execa(`yarn rw exec makeAdmin --email ${adminEmail}`, { + cwd: process.env.REDWOOD_PROJECT_PATH, + stdio: 'inherit', + shell: true, + }) + + await loginAsTestUser({ + page, + email: adminEmail, + password, + }) + + await page.goto('/contacts') + + // This makes the test less flaky when running locally against the same + // test project multiple times + const contactCountBefore = await waitForContact(page) + + page.once('dialog', (dialog) => { + console.log(`Dialog message: ${dialog.message()}`) + dialog.accept().catch(() => { + console.error('Failed to accept dialog') + }) + }) + + await page.locator('text=Delete').first().click() + + await expect( + page.locator('.rw-scaffold').locator('text=Contact deleted') + ).toBeVisible() + + await expect(await page.locator('text=charlie@chimichanga.com').count()).toBe( + contactCountBefore - 1 + ) +}) + +async function fillOutContactFormAsAnonymousUser({ + page, + messageDate, +}: { + page: PlaywrightTestArgs['page'] + messageDate: string +}) { + await page.goto('localhost:8910/contact') + // Click input[name="name"] + await page.locator('input[name="name"]').click() + // Fill input[name="name"] + await page.locator('input[name="name"]').fill('Charlie Chimichanga') + // Click input[name="email"] + await page.locator('input[name="email"]').click() + // Fill input[name="email"] + await page.locator('input[name="email"]').fill('charlie@chimichanga.com') + // Click textarea[name="message"] + await page.locator('textarea[name="message"]').click() + // Fill textarea[name="message"] + await page + .locator('textarea[name="message"]') + .fill(`Hello, I love Mexican food. What about you? - ${messageDate}`) + // Click text=Save + const successMessage = page.locator(`text=Thank you for your submission!`) + + await Promise.all([ + successMessage.waitFor({ timeout: 5000 }), + page.locator('text=Save').click(), + ]) +} + +function waitForContact(page: Page) { + const MAX_INTERVALS = 10 + + return new Promise((resolve) => { + let intervals = 0 + const watchInterval = setInterval(async () => { + console.log('Waiting for Charlie...') + const contactCount = await page + .locator('text=charlie@chimichanga.com') + .count() + + if (contactCount > 0) { + clearInterval(watchInterval) + resolve(contactCount) + } + + if (intervals > MAX_INTERVALS) { + expect(contactCount).toBeGreaterThan(0) + } + + intervals++ + }, 100) + }) +} diff --git a/tasks/smoke-tests/basePlaywright.config.ts b/tasks/smoke-tests/basePlaywright.config.ts new file mode 100644 index 000000000000..01e46ed162d0 --- /dev/null +++ b/tasks/smoke-tests/basePlaywright.config.ts @@ -0,0 +1,47 @@ +import type { PlaywrightTestConfig } from '@playwright/test' +import { devices } from '@playwright/test' +import { devices as replayDevices } from '@replayio/playwright' + +// See https://playwright.dev/docs/test-configuration#global-configuration +export const basePlaywrightConfig: PlaywrightTestConfig = { + testDir: './tests', + + // 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, + + projects: [ + { + name: 'replay-chromium', + use: { ...(replayDevices['Replay Chromium'] as any) }, + }, + + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // { + // name: 'replay-firefox', + // use: { ...(replayDevices['Replay Firefox'] as any) }, + // }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + ], + + // Use the Replay.io reporter in CI for debugging. + reporter: process.env.CI ? '@replayio/playwright/reporter' : 'list', +} diff --git a/tasks/smoke-test/tests/common.ts b/tasks/smoke-tests/common.ts similarity index 80% rename from tasks/smoke-test/tests/common.ts rename to tasks/smoke-tests/common.ts index 55cfd6beb1dd..5da5511454a8 100644 --- a/tasks/smoke-test/tests/common.ts +++ b/tasks/smoke-tests/common.ts @@ -1,11 +1,10 @@ import { expect, PlaywrightTestArgs } from '@playwright/test' -export const smokeTest = async ({ page, webServerPort }) => { - // Go to http://localhost:8910/ - await page.goto(`http://localhost:${webServerPort}/`) +export async function smokeTest({ page }: PlaywrightTestArgs) { + await page.goto('/') - // Check that the blog posts are being loaded - // Avoid checking titles, because we edit them in other tests + // Check that the blog posts are being loaded. + // Avoid checking titles because we edit them in other tests. await page.textContent('text=Meh waistcoat succulents umami') await page.textContent('text=Raclette shoreditch before they sold out lyft.') await page.textContent( @@ -15,22 +14,21 @@ export const smokeTest = async ({ page, webServerPort }) => { // Click text=About await page.click('text=About') - expect(page.url()).toBe(`http://localhost:${webServerPort}/about`) + expect(page.url()).toBe('http://localhost:8910/about') await page.textContent( 'text=This site was created to demonstrate my mastery of Redwood: Look on my works, ye' ) // Click text=Contact await page.click('text=Contact') - expect(page.url()).toBe(`http://localhost:${webServerPort}/contact`) + expect(page.url()).toBe('http://localhost:8910/contact') // Click text=Admin await page.click('text=Admin') - expect(page.url()).toBe(`http://localhost:${webServerPort}/posts`) + expect(page.url()).toBe('http://localhost:8910/posts') } interface AuthUtilsParams { - webUrl: string email?: string password?: string fullName?: string @@ -38,13 +36,12 @@ interface AuthUtilsParams { } export const signUpTestUser = async ({ - webUrl, page, email = 'testuser@bazinga.com', password = 'test123', fullName = 'Test User', }: AuthUtilsParams) => { - await page.goto(`${webUrl}/signup`) + await page.goto('/signup') await page.locator('input[name="username"]').click() // Fill input[name="username"] @@ -74,12 +71,11 @@ export const signUpTestUser = async ({ } export const loginAsTestUser = async ({ - webUrl, page, email = 'testuser@bazinga.com', password = 'test123', }: AuthUtilsParams) => { - await page.goto(`${webUrl}/login`) + await page.goto('/login') // Click input[name="username"] await page.locator('input[name="username"]').click() diff --git a/tasks/smoke-tests/dev/playwright.config.ts b/tasks/smoke-tests/dev/playwright.config.ts new file mode 100644 index 000000000000..6d7c2f88e011 --- /dev/null +++ b/tasks/smoke-tests/dev/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from '@playwright/test' + +import { basePlaywrightConfig } from '../basePlaywright.config' + +// See https://playwright.dev/docs/test-configuration#global-configuration +export default defineConfig({ + ...basePlaywrightConfig, + + timeout: 30_000 * 2, + + use: { + baseURL: 'http://localhost:8910', + }, + + // Run your local dev server before starting the tests + webServer: { + command: 'yarn redwood dev --no-generate --fwd="--no-open"', + cwd: process.env.REDWOOD_PROJECT_PATH, + // We wait for the api server to be ready instead of the web server + // because web starts much faster with Vite. + url: 'http://localhost:8911/graphql?query={redwood{version}}', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, +}) diff --git a/tasks/smoke-tests/dev/tests/dev.spec.ts b/tasks/smoke-tests/dev/tests/dev.spec.ts new file mode 100644 index 000000000000..c36dd405d595 --- /dev/null +++ b/tasks/smoke-tests/dev/tests/dev.spec.ts @@ -0,0 +1,5 @@ +import { test } from '@playwright/test' + +import { smokeTest } from '../../common' + +test('Smoke test with dev server', smokeTest) diff --git a/tasks/smoke-tests/prerender/playwright.config.ts b/tasks/smoke-tests/prerender/playwright.config.ts new file mode 100644 index 000000000000..45f2ae050faa --- /dev/null +++ b/tasks/smoke-tests/prerender/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test' + +import { basePlaywrightConfig } from '../basePlaywright.config' + +// See https://playwright.dev/docs/test-configuration#global-configuration +export default defineConfig({ + ...basePlaywrightConfig, + + use: { + baseURL: 'http://localhost:8910', + }, + + // Run your local dev server before starting the tests + webServer: { + command: 'yarn redwood serve', + cwd: process.env.REDWOOD_PROJECT_PATH, + url: 'http://localhost:8910', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, +}) diff --git a/tasks/smoke-tests/prerender/tests/prerender.spec.ts b/tasks/smoke-tests/prerender/tests/prerender.spec.ts new file mode 100644 index 000000000000..86480eac1d3f --- /dev/null +++ b/tasks/smoke-tests/prerender/tests/prerender.spec.ts @@ -0,0 +1,326 @@ +import fs from 'fs' +import path from 'path' + +import { + test, + BrowserContext, + expect, + PlaywrightTestArgs, + PlaywrightWorkerArgs, +} from '@playwright/test' +import execa from 'execa' + +let noJsBrowser: BrowserContext + +test.beforeAll(async ({ browser }: PlaywrightWorkerArgs) => { + noJsBrowser = await browser.newContext({ + javaScriptEnabled: false, + }) +}) + +test('Check that homepage is prerendered', async () => { + const pageWithoutJs = await noJsBrowser.newPage() + await pageWithoutJs.goto('/') + + const cellSuccessState = await pageWithoutJs.locator('main').innerHTML() + expect(cellSuccessState).toMatch(/Welcome to the blog!/) + expect(cellSuccessState).toMatch(/A little more about me/) + expect(cellSuccessState).toMatch(/What is the meaning of life\?/) + + const navTitle = await pageWithoutJs.locator('header >> h1').innerText() + expect(navTitle).toBe('Redwood Blog') + + const navLinks = await pageWithoutJs.locator('nav >> ul').innerText() + expect(navLinks.split('\n')).toEqual([ + 'About', + 'Contact Us', + 'Admin', + 'Log In', + ]) + + pageWithoutJs.close() +}) + +test('Check that rehydration works for page not wrapped in Set', async ({ + page, +}: PlaywrightTestArgs) => { + const errors: string[] = [] + + page.on('pageerror', (err) => { + errors.push(err.message) + }) + + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(message.text()) + } + }) + + await page.goto('/double') + + // Wait for page to have been rehydrated before getting page content. + // We know the page has been rehydrated when it sends an auth request + await page.waitForResponse((response) => + response.url().includes('/.redwood/functions/auth') + ) + + await page.locator('h1').first().waitFor() + const headerCount = await page + .locator('h1', { hasText: 'DoublePage' }) + .count() + expect(headerCount).toEqual(1) + + const bodyText = await page.locator('body').innerText() + expect(bodyText.match(/#7757/g)).toHaveLength(1) + + const title = await page.locator('title').innerText() + expect(title).toBe('Double | Redwood App') + + expect(errors).toMatchObject([]) + + page.close() +}) + +test('Check that rehydration works for page with Cell in Set', async ({ + page, +}: PlaywrightTestArgs) => { + const errors: string[] = [] + + page.on('pageerror', (err) => { + errors.push(err.message) + }) + + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(message.text()) + } + }) + + await page.goto('/') + + // Wait for page to have been rehydrated and cells have fetched their data + // before getting page content. + // We know cells have started fetching data when we see graphql requests + await page.waitForResponse((response) => + response.url().includes('/.redwood/functions/graphql') + ) + + await page.locator('h2').first().waitFor() + const mainText = await page.locator('main').innerText() + expect(mainText.match(/Welcome to the blog!/g)).toHaveLength(1) + expect(mainText.match(/A little more about me/g)).toHaveLength(1) + + // Something strange is going on here. Sometimes React generates errors, + // sometimes it doesn't. + // The problem is we have a Cell with prerendered content. Then when the + // page is rehydrated JS kicks in and the cell fetches data from the + // server. While it's getting the data "Loading..." is shown. This doesn't + // match up with the content that was prerendered, so React complains. + // We have a around the Cell, so React will stop at the + // suspense boundary and do full CSR of the cell content (instead of + // throwing away the entire page and do a CSR of the whole thing as it + // would have done without the ). + // Until we fully understand why we only get the errors sometimes we can't + // have this `expect` enabled + // expect(errors).toMatchObject([]) + + page.close() +}) + +test('Check that rehydration works for page with code split chunks', async ({ + page, +}: PlaywrightTestArgs) => { + const errors: string[] = [] + + page.on('pageerror', (err) => { + errors.push(err.message) + }) + + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(message.text()) + } + }) + + // This page uses Redwood Forms, and so does /posts/new. Webpack splits rw + // forms out into a separate chunk. We need to make sure our prerender + // code can handle that + await page.goto('/contacts/new') + + // Wait for page to have been rehydrated before getting page content. + // We know the page has been rehydrated when it sends an auth request + await page.waitForResponse((response) => + response.url().includes('/.redwood/functions/auth') + ) + + await expect(page.getByLabel('Name')).toBeVisible() + await expect(page.getByLabel('Email')).toBeVisible() + await expect(page.getByLabel('Message')).toBeVisible() + + expect(errors).toMatchObject([]) + + page.close() +}) + +test('Check that a specific blog post is prerendered', async () => { + const pageWithoutJs = await noJsBrowser.newPage() + + // It's non-deterministic what id the posts get, so we have to first find + // the url of a given post, and then navigate to that + + await pageWithoutJs.goto('/') + + const meaningOfLifeHref = await pageWithoutJs + .locator('a:has-text("What is the meaning of life?")') + .getAttribute('href') + + await pageWithoutJs.goto(meaningOfLifeHref as string) + + const mainContent = await pageWithoutJs.locator('main').innerHTML() + expect(mainContent).toMatch(/What is the meaning of life\?/) + // Test that nested cell content is also rendered + expect(mainContent).toMatch(/user\.two@example\.com/) + expect(mainContent).not.toMatch(/Welcome to the blog!/) + expect(mainContent).not.toMatch(/A little more about me/) + + const navTitle = await pageWithoutJs.locator('header >> h1').innerText() + expect(navTitle).toBe('Redwood Blog') + + const navLinks = await pageWithoutJs.locator('nav >> ul').innerText() + expect(navLinks.split('\n')).toEqual([ + 'About', + 'Contact Us', + 'Admin', + 'Log In', + ]) + + pageWithoutJs.close() +}) + +test('Check that meta-tags are rendering the correct dynamic data', async () => { + const pageWithoutJs = await noJsBrowser.newPage() + + await pageWithoutJs.goto('/blog-post/1') + + const metaDescription = await pageWithoutJs.locator( + 'meta[name="description"]' + ) + + const ogDescription = await pageWithoutJs.locator( + 'meta[property="og:description"]' + ) + await expect(metaDescription).toHaveAttribute('content', 'Description 1') + await expect(ogDescription).toHaveAttribute('content', 'Description 1') + + const title = await pageWithoutJs.locator('title').innerHTML() + await expect(title).toBe('Post 1 | Redwood App') + + const ogTitle = await pageWithoutJs.locator('meta[property="og:title"]') + await expect(ogTitle).toHaveAttribute('content', 'Post 1') +}) + +test('Check that you can navigate from home page to specific blog post', async () => { + const pageWithoutJs = await noJsBrowser.newPage() + await pageWithoutJs.goto('/') + + let mainContent = await pageWithoutJs.locator('main').innerHTML() + expect(mainContent).toMatch(/Welcome to the blog!/) + expect(mainContent).toMatch(/A little more about me/) + expect(mainContent).toMatch(/What is the meaning of life\?/) + + await pageWithoutJs.goto('/') + const aboutMeAnchor = await pageWithoutJs.locator( + 'a:has-text("A little more about me")' + ) + + await aboutMeAnchor.click() + + const aboutMeAnchorHref = (await aboutMeAnchor.getAttribute('href')) || '' + expect(aboutMeAnchorHref).not.toEqual('') + + mainContent = await pageWithoutJs.locator('main').innerHTML() + expect(mainContent).toMatch(/A little more about me/) + expect(mainContent).not.toMatch(/Welcome to the blog!/) + expect(mainContent).not.toMatch(/What is the meaning of life\?/) + expect(pageWithoutJs.url()).toMatch( + new RegExp(escapeRegExp(aboutMeAnchorHref)) + ) + + pageWithoutJs.close() +}) + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping +function escapeRegExp(string: string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string +} + +test('Check that about is prerendered', async () => { + const pageWithoutJs = await noJsBrowser.newPage() + await pageWithoutJs.goto('/about') + + const aboutPageContent = await pageWithoutJs.locator('main').innerText() + expect(aboutPageContent).toBe( + 'This site was created to demonstrate my mastery of Redwood: Look on my works, ye mighty, and despair!' + ) + pageWithoutJs.close() +}) + +// We don't really need a server running here. So we could just use `test()` +// straight from playwright. But we do need to have the project built. And +// `rwServeTest()` does that. If we try to add project building to this test as +// well we will build twice, and we don't want that. Hence we use rwServeTest. +test('prerender with broken gql query', async () => { + const redwoodProjectPath = process.env.REDWOOD_PROJECT_PATH || '' + + const cellBasePath = path.join( + redwoodProjectPath, + 'web', + 'src', + 'components', + 'BlogPostsCell' + ) + + const cellPathJs = path.join(cellBasePath, 'BlogPostsCell.js') + const cellPathTs = path.join(cellBasePath, 'BlogPostsCell.tsx') + const cellPath = fs.existsSync(cellPathTs) ? cellPathTs : cellPathJs + + const blogPostsCell = fs.readFileSync(cellPath, 'utf-8') + fs.writeFileSync(cellPath, blogPostsCell.replace('createdAt', 'timestamp')) + + try { + await execa.command(`yarn rw prerender`, { + cwd: redwoodProjectPath, + shell: true, + }) + } catch (e) { + expect(e.message).toMatch( + /GQL error: Cannot query field "timestamp" on type "Post"\./ + ) + } + + // Restore cell + fs.writeFileSync(cellPath, blogPostsCell) + + // Make sure to restore any potentially broken/missing prerendered pages + await execa.command(`yarn rw prerender`, { + cwd: redwoodProjectPath, + shell: true, + }) +}) + +test('Waterfall prerendering (nested cells)', async () => { + const pageWithoutJs = await noJsBrowser.newPage() + + // It's non-deterministic what id the posts get, so we're pretty generic + // with what we're matching in this test case + + await pageWithoutJs.goto('/waterfall/2') + + const mainContent = await pageWithoutJs.locator('main').innerHTML() + expect(mainContent).toMatch(/[\w\s?!]+<\/h2><\/header>/) + // Test that nested cell content is also rendered + expect(mainContent).toMatch(/class="author-cell"/) + expect(mainContent).toMatch(/user.(one|two)@example.com/) + + pageWithoutJs.close() +}) diff --git a/tasks/smoke-tests/serve/playwright.config.ts b/tasks/smoke-tests/serve/playwright.config.ts new file mode 100644 index 000000000000..45f2ae050faa --- /dev/null +++ b/tasks/smoke-tests/serve/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test' + +import { basePlaywrightConfig } from '../basePlaywright.config' + +// See https://playwright.dev/docs/test-configuration#global-configuration +export default defineConfig({ + ...basePlaywrightConfig, + + use: { + baseURL: 'http://localhost:8910', + }, + + // Run your local dev server before starting the tests + webServer: { + command: 'yarn redwood serve', + cwd: process.env.REDWOOD_PROJECT_PATH, + url: 'http://localhost:8910', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, +}) diff --git a/tasks/smoke-tests/serve/tests/serve.spec.ts b/tasks/smoke-tests/serve/tests/serve.spec.ts new file mode 100644 index 000000000000..08fbb4cd3c82 --- /dev/null +++ b/tasks/smoke-tests/serve/tests/serve.spec.ts @@ -0,0 +1,5 @@ +import { test } from '@playwright/test' + +import { smokeTest } from '../../common' + +test('Smoke test with rw serve', smokeTest) diff --git a/tasks/smoke-tests/storybook/playwright.config.ts b/tasks/smoke-tests/storybook/playwright.config.ts new file mode 100644 index 000000000000..3406a9738a62 --- /dev/null +++ b/tasks/smoke-tests/storybook/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from '@playwright/test' + +import { basePlaywrightConfig } from '../basePlaywright.config' + +// See https://playwright.dev/docs/test-configuration#global-configuration +export default defineConfig({ + ...basePlaywrightConfig, + + use: { + baseURL: 'http://localhost:7910', + }, + + // Run your local dev server before starting the tests + webServer: { + command: 'yarn redwood storybook --ci --no-open', + cwd: process.env.REDWOOD_PROJECT_PATH, + url: 'http://localhost:7910', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + // The Storybook v7 CLI seems noticeably slower, and it times out in Windows CI. + timeout: 60_000 * 2, + }, +}) diff --git a/tasks/smoke-tests/storybook/tests/storybook.spec.ts b/tasks/smoke-tests/storybook/tests/storybook.spec.ts new file mode 100644 index 000000000000..9a0712c4457b --- /dev/null +++ b/tasks/smoke-tests/storybook/tests/storybook.spec.ts @@ -0,0 +1,163 @@ +import fs from 'fs' +import path from 'path' + +import { test, expect } from '@playwright/test' +import type { PlaywrightTestArgs } from '@playwright/test' + +test('Loads Cell stories', async ({ page }: PlaywrightTestArgs) => { + await page.goto('/') + + // Click text=BlogPostCell + await page.locator('text=/\\bBlogPostCell\\b/').click() + + await expect(page).toHaveURL( + `http://localhost:7910/?path=/story/cells-blogpostcell--loading` + ) + + await expect( + page.frameLocator('#storybook-preview-iframe').locator('body') + ).toContainText('Loading...') + + // Click text=Failure + await page.locator('text=Failure').click() + await expect(page).toHaveURL( + `http://localhost:7910/?path=/story/cells-blogpostcell--failure` + ) + + await expect( + page.frameLocator('#storybook-preview-iframe').locator('body') + ).toContainText('Error: Oh no') + + // Check Loading + await page.locator('text=Empty').click() + await expect(page).toHaveURL( + `http://localhost:7910/?path=/story/cells-blogpostcell--empty` + ) + + await expect( + page.frameLocator('#storybook-preview-iframe').locator('body') + ).toContainText('Empty') + + // Check Success + // And make sure MSW Cell mocks are loaded as expected + await page.locator('text=Success').click() + await expect(page).toHaveURL( + `http://localhost:7910/?path=/story/cells-blogpostcell--success` + ) + + await expect( + page.frameLocator('#storybook-preview-iframe').locator('body') + ).toContainText('Mocked title') + + await expect( + page.frameLocator('#storybook-preview-iframe').locator('body') + ).toContainText('Mocked body') +}) + +test('Loads Cell mocks when Cell is nested in another story', async ({ + page, +}: PlaywrightTestArgs) => { + await page.goto('/') + + // Click text=BlogPostCell + await page.locator('text=BlogPostPage').click() + + // Click text=Empty + await expect(page).toHaveURL( + `http://localhost:7910/?path=/story/pages-blogpostpage--generated` + ) + + await expect( + page.frameLocator('#storybook-preview-iframe').locator('body') + ).toContainText('Mocked title') + + await expect( + page.frameLocator('#storybook-preview-iframe').locator('body') + ).toContainText('Mocked body') +}) + +test('Mocks current user, and updates UI while dev server is running', async ({ + page, +}: PlaywrightTestArgs) => { + const profileStoryPath = path.join( + process.env.REDWOOD_PROJECT_PATH as string, + 'web/src/pages/ProfilePage/ProfilePage.stories.tsx' + ) + + // Modify profile page stories to mockCurrentUser + const profilePageStoryContent = fs.readFileSync(profileStoryPath, 'utf-8') + + if (!profilePageStoryContent.includes('mockCurrentUser')) { + const contentWithMockCurrentUser = profilePageStoryContent.replace( + 'export const generated = () => {', + MOCK_CURRENT_USER_CONTENT + ) + + fs.writeFileSync(profileStoryPath, contentWithMockCurrentUser) + } + + await page.goto('/') + + await Promise.all([ + page.waitForLoadState(), + page.waitForSelector('text=ProfilePage'), + ]) + + await page.locator('text=ProfilePage').click() + + try { + await page + .frameLocator('#storybook-preview-iframe') + .locator('css=h1 >> text=Profile') + .waitFor({ timeout: 5_000 }) + } catch { + await page.reload() + } + + const usernameRow = await page + .frameLocator('#storybook-preview-iframe') + .locator('*css=tr >> text=EMAIL') + await expect(await usernameRow.innerHTML()).toBe( + 'EMAILba@zinga.com' + ) + + const isAuthenticatedRow = await page + .frameLocator('#storybook-preview-iframe') + .locator('*css=tr >> text=isAuthenticated') + await expect(await isAuthenticatedRow.innerHTML()).toBe( + 'isAuthenticatedtrue' + ) + + const isAdminRow = await page + .frameLocator('#storybook-preview-iframe') + .locator('*css=tr >> text=Is Admin') + await expect(await isAdminRow.innerHTML()).toBe( + 'Is Admintrue' + ) +}) + +const MOCK_CURRENT_USER_CONTENT = `\ +export const generated = () => { + mockCurrentUser({ + email: 'ba@zinga.com', + id: 55, + roles: 'ADMIN', + }) +` + +test('Loads MDX Stories', async ({ page }: PlaywrightTestArgs) => { + await page.goto('/') + + // Click Redwood link in left nav + await page.locator('id=redwood--docs').click() + + await expect(page).toHaveURL( + `http://localhost:7910/?path=/docs/redwood--docs` + ) + + await expect( + page.frameLocator('#storybook-preview-iframe').locator('body') + ).toContainText( + 'Redwood is an opinionated, full-stack, JavaScript/TypeScript web application framework designed to keep you moving fast as your app grows from side project to startup.' + ) +}) diff --git a/yarn.lock b/yarn.lock index bae9bac2879a..c7c6608955b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,7 +5,25 @@ __metadata: version: 6 cacheKey: 8c0 -"@actions/core@npm:1.10.0": +"@actions/cache@npm:3.2.1": + version: 3.2.1 + resolution: "@actions/cache@npm:3.2.1" + dependencies: + "@actions/core": ^1.10.0 + "@actions/exec": ^1.0.1 + "@actions/glob": ^0.1.0 + "@actions/http-client": ^2.0.1 + "@actions/io": ^1.0.1 + "@azure/abort-controller": ^1.1.0 + "@azure/ms-rest-js": ^2.6.0 + "@azure/storage-blob": ^12.13.0 + semver: ^6.1.0 + uuid: ^3.3.3 + checksum: eae40199a2b32abc72803143fbb9aa81077098bcd15b027b9b9485ccbea59fc03585f6b75068bf6f5becac3c63da9131138f13e6566bfe678372f433dcb72b79 + languageName: node + linkType: hard + +"@actions/core@npm:1.10.0, @actions/core@npm:^1.10.0, @actions/core@npm:^1.2.6, @actions/core@npm:^1.9.1": version: 1.10.0 resolution: "@actions/core@npm:1.10.0" dependencies: @@ -15,7 +33,7 @@ __metadata: languageName: node linkType: hard -"@actions/exec@npm:1.1.1": +"@actions/exec@npm:1.1.1, @actions/exec@npm:^1.0.1": version: 1.1.1 resolution: "@actions/exec@npm:1.1.1" dependencies: @@ -24,6 +42,26 @@ __metadata: languageName: node linkType: hard +"@actions/glob@npm:0.4.0": + version: 0.4.0 + resolution: "@actions/glob@npm:0.4.0" + dependencies: + "@actions/core": ^1.9.1 + minimatch: ^3.0.4 + checksum: f50f029244b216676184ec00034508594cc750ed7efc2ebff490eb407352526424e1280cbf7fef95a75247f54a1732277ef6c007ce193aeca8430e7f4d356d33 + languageName: node + linkType: hard + +"@actions/glob@npm:^0.1.0": + version: 0.1.2 + resolution: "@actions/glob@npm:0.1.2" + dependencies: + "@actions/core": ^1.2.6 + minimatch: ^3.0.4 + checksum: 7431cb85da7df2bab8dac54885410cbd695ae70b516a70b642d59df3e444030e4bbc8b103226e8c98130ee81f024739aefbec3bf20dff8a280724c4fae8be492 + languageName: node + linkType: hard + "@actions/http-client@npm:^2.0.1": version: 2.1.0 resolution: "@actions/http-client@npm:2.1.0" @@ -351,6 +389,114 @@ __metadata: languageName: node linkType: hard +"@azure/abort-controller@npm:^1.0.0, @azure/abort-controller@npm:^1.1.0": + version: 1.1.0 + resolution: "@azure/abort-controller@npm:1.1.0" + dependencies: + tslib: ^2.2.0 + checksum: bb79f0faaa9e9c1ae3c4ec2523ea23ee0879cc491abb4b3ac2dd56c2cc2dfe4b7e8522ffa866d39c7145c0dd61387711368afe0d4eb6534daba7b67ed0a2a730 + languageName: node + linkType: hard + +"@azure/core-auth@npm:^1.1.4, @azure/core-auth@npm:^1.3.0": + version: 1.4.0 + resolution: "@azure/core-auth@npm:1.4.0" + dependencies: + "@azure/abort-controller": ^1.0.0 + tslib: ^2.2.0 + checksum: c139da4a439703bc3d456dbdc8e84f430d9e5245442e9991830613825de10e218cf92d6bfbf1431ebef789565c33e440cc9cf8dd0134025b841937c512621588 + languageName: node + linkType: hard + +"@azure/core-http@npm:^3.0.0": + version: 3.0.1 + resolution: "@azure/core-http@npm:3.0.1" + dependencies: + "@azure/abort-controller": ^1.0.0 + "@azure/core-auth": ^1.3.0 + "@azure/core-tracing": 1.0.0-preview.13 + "@azure/core-util": ^1.1.1 + "@azure/logger": ^1.0.0 + "@types/node-fetch": ^2.5.0 + "@types/tunnel": ^0.0.3 + form-data: ^4.0.0 + node-fetch: ^2.6.7 + process: ^0.11.10 + tslib: ^2.2.0 + tunnel: ^0.0.6 + uuid: ^8.3.0 + xml2js: ^0.5.0 + checksum: 3e74fa4f7aab2b5f3fe2256b0e922f331c21f1367f3e1e069802126d3e3ef3235f9df07180919acd82b9ea5869d42ea55cd73ee31cf0fcf9db4a2c5fa3607b6a + languageName: node + linkType: hard + +"@azure/core-lro@npm:^2.2.0": + version: 2.5.3 + resolution: "@azure/core-lro@npm:2.5.3" + dependencies: + "@azure/abort-controller": ^1.0.0 + "@azure/core-util": ^1.2.0 + "@azure/logger": ^1.0.0 + tslib: ^2.2.0 + checksum: 3f0acd7bf9f601661b0d226a13edf4048f08b9217d91ae6e2d35764ff80b05d5530e6dfd87a6e4b99937df22bb720363f9edb5be61c62d4eaffd3020c6618c8b + languageName: node + linkType: hard + +"@azure/core-paging@npm:^1.1.1": + version: 1.5.0 + resolution: "@azure/core-paging@npm:1.5.0" + dependencies: + tslib: ^2.2.0 + checksum: 634a1c6540d16cf047035f19d8866b561e8036059aec2ce0304d77999404e95458f47c33a3f523d20cefa329bad33ccc6bf7450efa8f7d77ceb3419d519b1c48 + languageName: node + linkType: hard + +"@azure/core-tracing@npm:1.0.0-preview.13": + version: 1.0.0-preview.13 + resolution: "@azure/core-tracing@npm:1.0.0-preview.13" + dependencies: + "@opentelemetry/api": ^1.0.1 + tslib: ^2.2.0 + checksum: 0977479165deefe1dcabbd68d18e44742ad18fa4bd0200b9d8b6647510c200800e8b47f8039a249086de9ff7eda37ea3f2beb85fa4878a08dd0251a71ea0cbe3 + languageName: node + linkType: hard + +"@azure/core-util@npm:^1.1.1, @azure/core-util@npm:^1.2.0": + version: 1.3.2 + resolution: "@azure/core-util@npm:1.3.2" + dependencies: + "@azure/abort-controller": ^1.0.0 + tslib: ^2.2.0 + checksum: bfe97fc99cb4fac5633f0fbd5a459233672f050083883bbd4b260aa3b82c7143efef0e05846d787c5864ca59825be0fb14699daa8a32b318efc1373345c0e5ee + languageName: node + linkType: hard + +"@azure/logger@npm:^1.0.0": + version: 1.0.4 + resolution: "@azure/logger@npm:1.0.4" + dependencies: + tslib: ^2.2.0 + checksum: 15af549d8dbf027e7520fc65432577d52c73b5a30bce2c218f97ab7104b037ae6c31d9a5bfa6bc9c7873c05693261ab8d7f5b95c65db6b1a7c8624c7b655afc6 + languageName: node + linkType: hard + +"@azure/ms-rest-js@npm:^2.6.0": + version: 2.6.6 + resolution: "@azure/ms-rest-js@npm:2.6.6" + dependencies: + "@azure/core-auth": ^1.1.4 + abort-controller: ^3.0.0 + form-data: ^2.5.0 + node-fetch: ^2.6.7 + tough-cookie: ^3.0.1 + tslib: ^1.10.0 + tunnel: 0.0.6 + uuid: ^8.3.2 + xml2js: ^0.5.0 + checksum: 251f33a7746ca1f0a684a6956978f7285de259653d5ebb62d397a8a30b797baf475981b84cf9772066008537a9cc61231c32456a380cc6cf9d1e5f2424c11fdf + languageName: node + linkType: hard + "@azure/msal-browser@npm:2.37.0": version: 2.37.0 resolution: "@azure/msal-browser@npm:2.37.0" @@ -367,6 +513,22 @@ __metadata: languageName: node linkType: hard +"@azure/storage-blob@npm:^12.13.0": + version: 12.14.0 + resolution: "@azure/storage-blob@npm:12.14.0" + dependencies: + "@azure/abort-controller": ^1.0.0 + "@azure/core-http": ^3.0.0 + "@azure/core-lro": ^2.2.0 + "@azure/core-paging": ^1.1.1 + "@azure/core-tracing": 1.0.0-preview.13 + "@azure/logger": ^1.0.0 + events: ^3.0.0 + tslib: ^2.2.0 + checksum: 5a97148cc20fa906d335a960a9cf71e13d4fda3e128db74738e957965dd977db0dab4ca4401bfc57b9c5f3db5d3b8c736e6a0f83950d4da437a61c3403217d89 + languageName: node + linkType: hard + "@babel/cli@npm:7.21.5": version: 7.21.5 resolution: "@babel/cli@npm:7.21.5" @@ -6033,7 +6195,7 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/api@npm:1.4.1, @opentelemetry/api@npm:^1.0.0": +"@opentelemetry/api@npm:1.4.1, @opentelemetry/api@npm:^1.0.0, @opentelemetry/api@npm:^1.0.1": version: 1.4.1 resolution: "@opentelemetry/api@npm:1.4.1" checksum: 5ee641d3d64c91e87ee328fc22251fc70c809a3c744e51e595ca77c0bd3cad933b77a79beb4dac66b811e5068941cef9da58c1ec217c0748a01f598e08a7ae66 @@ -10018,7 +10180,7 @@ __metadata: languageName: node linkType: hard -"@types/node-fetch@npm:^2.5.7, @types/node-fetch@npm:^2.6.1, @types/node-fetch@npm:^2.6.2": +"@types/node-fetch@npm:^2.5.0, @types/node-fetch@npm:^2.5.7, @types/node-fetch@npm:^2.6.1, @types/node-fetch@npm:^2.6.2": version: 2.6.4 resolution: "@types/node-fetch@npm:2.6.4" dependencies: @@ -10307,6 +10469,15 @@ __metadata: languageName: node linkType: hard +"@types/tunnel@npm:^0.0.3": + version: 0.0.3 + resolution: "@types/tunnel@npm:0.0.3" + dependencies: + "@types/node": "*" + checksum: 6d479136e541bc080ae8c71ff794b97c513d2787116e0dffb6ffdfb69f2257422e928a585fe84b3ae3a997e99d712b65d0c3fabf43a0980a483e83a042644ace + languageName: node + linkType: hard + "@types/unist@npm:^2.0.0": version: 2.0.6 resolution: "@types/unist@npm:2.0.6" @@ -17681,6 +17852,17 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^2.5.0": + version: 2.5.1 + resolution: "form-data@npm:2.5.1" + dependencies: + asynckit: ^0.4.0 + combined-stream: ^1.0.6 + mime-types: ^2.1.12 + checksum: 7e8fb913b84a7ac04074781a18d0f94735bbe82815ff35348803331f6480956ff0035db5bcf15826edee09fe01e665cfac664678f1526646a6374ee13f960e56 + languageName: node + linkType: hard + "form-data@npm:^3.0.0": version: 3.0.1 resolution: "form-data@npm:3.0.1" @@ -19611,6 +19793,13 @@ __metadata: languageName: node linkType: hard +"ip-regex@npm:^2.1.0": + version: 2.1.0 + resolution: "ip-regex@npm:2.1.0" + checksum: 3ce2d8307fa0373ca357eba7504e66e73b8121805fd9eba6a343aeb077c64c30659fa876b11ac7a75635b7529d2ce87723f208a5b9d51571513b5c68c0cc1541 + languageName: node + linkType: hard + "ip@npm:^2.0.0": version: 2.0.0 resolution: "ip@npm:2.0.0" @@ -27345,8 +27534,10 @@ __metadata: version: 0.0.0-use.local resolution: "root-workspace-0b6124@workspace:." dependencies: + "@actions/cache": 3.2.1 "@actions/core": 1.10.0 "@actions/exec": 1.1.1 + "@actions/glob": 0.4.0 "@babel/cli": 7.21.5 "@babel/core": 7.22.1 "@babel/generator": 7.22.3 @@ -27713,7 +27904,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.0.0, semver@npm:^6.1.1, semver@npm:^6.1.2, semver@npm:^6.2.0, semver@npm:^6.3.0": +"semver@npm:^6.0.0, semver@npm:^6.1.0, semver@npm:^6.1.1, semver@npm:^6.1.2, semver@npm:^6.2.0, semver@npm:^6.3.0": version: 6.3.0 resolution: "semver@npm:6.3.0" bin: @@ -29523,6 +29714,17 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^3.0.1": + version: 3.0.1 + resolution: "tough-cookie@npm:3.0.1" + dependencies: + ip-regex: ^2.1.0 + psl: ^1.1.28 + punycode: ^2.1.1 + checksum: 312fdfd169c719494529990d0bb774cd5082de7da6e36869af4d0aa58fa2a973d82ba1f06b3e232e797ddfdd4f7843d6406e1886bd81b7ab003c6b5de8f08d18 + languageName: node + linkType: hard + "tough-cookie@npm:^4.1.2": version: 4.1.2 resolution: "tough-cookie@npm:4.1.2" @@ -29726,14 +29928,14 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^1.8.1, tslib@npm:^1.9.0, tslib@npm:^1.9.2": +"tslib@npm:^1.10.0, tslib@npm:^1.8.1, tslib@npm:^1.9.0, tslib@npm:^1.9.2": version: 1.14.1 resolution: "tslib@npm:1.14.1" checksum: 69ae09c49eea644bc5ebe1bca4fa4cc2c82b7b3e02f43b84bd891504edf66dbc6b2ec0eef31a957042de2269139e4acff911e6d186a258fb14069cd7f6febce2 languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.4.1, tslib@npm:^2.5.0, tslib@npm:~2.5.0": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.4.1, tslib@npm:^2.5.0, tslib@npm:~2.5.0": version: 2.5.2 resolution: "tslib@npm:2.5.2" checksum: 34fa100454708fa8acb7afc2b07d80e0332081e2075ddd912ba959af3b24f969663dac6d602961e57371dc05683badb83b3186ada92c4631ec777e02e3aab608 @@ -29795,7 +29997,7 @@ __metadata: languageName: node linkType: hard -"tunnel@npm:^0.0.6": +"tunnel@npm:0.0.6, tunnel@npm:^0.0.6": version: 0.0.6 resolution: "tunnel@npm:0.0.6" checksum: e27e7e896f2426c1c747325b5f54efebc1a004647d853fad892b46d64e37591ccd0b97439470795e5262b5c0748d22beb4489a04a0a448029636670bfd801b75 @@ -30494,7 +30696,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:8.3.2, uuid@npm:^8.0.0, uuid@npm:^8.3.2": +"uuid@npm:8.3.2, uuid@npm:^8.0.0, uuid@npm:^8.3.0, uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" bin: @@ -30512,7 +30714,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^3.3.2": +"uuid@npm:^3.3.2, uuid@npm:^3.3.3": version: 3.4.0 resolution: "uuid@npm:3.4.0" bin: @@ -31510,7 +31712,7 @@ __metadata: languageName: node linkType: hard -"xml2js@npm:0.5.0": +"xml2js@npm:0.5.0, xml2js@npm:^0.5.0": version: 0.5.0 resolution: "xml2js@npm:0.5.0" dependencies: