diff --git a/.github/actions/actionsLib.mjs b/.github/actions/actionsLib.mjs index 686beb1dc88d..0ef01d2313b2 100644 --- a/.github/actions/actionsLib.mjs +++ b/.github/actions/actionsLib.mjs @@ -62,9 +62,9 @@ export function projectCopy(redwoodProjectCwd) { } /** - * @param {{ baseKeyPrefix: string, distKeyPrefix: string }} options + * @param {{ baseKeyPrefix: string, distKeyPrefix: string, canary: boolean }} options */ -export async function createCacheKeys({ baseKeyPrefix, distKeyPrefix }) { +export async function createCacheKeys({ baseKeyPrefix, distKeyPrefix, canary }) { const baseKey = [ baseKeyPrefix, process.env.RUNNER_OS, @@ -76,7 +76,7 @@ export async function createCacheKeys({ baseKeyPrefix, distKeyPrefix }) { baseKey, 'dependencies', await hashFiles(['yarn.lock', '.yarnrc.yml'].join('\n')), - ].join('-') + ].join('-') + (canary ? '-canary' : '') const distKey = [ dependenciesKey, @@ -91,7 +91,7 @@ export async function createCacheKeys({ baseKeyPrefix, distKeyPrefix }) { 'lerna.json', 'packages', ].join('\n')) - ].join('-') + ].join('-') + (canary ? '-canary' : '') return { baseKey, diff --git a/.github/actions/set-up-test-project/action.yaml b/.github/actions/set-up-test-project/action.yaml index 9866507ab1db..0c0e623056a5 100644 --- a/.github/actions/set-up-test-project/action.yaml +++ b/.github/actions/set-up-test-project/action.yaml @@ -9,6 +9,9 @@ inputs: bundler: description: The bundler to use (vite or webpack) default: vite + canary: + description: Upgrade the project to canary? + default: "false" outputs: test-project-path: diff --git a/.github/actions/set-up-test-project/setUpTestProject.mjs b/.github/actions/set-up-test-project/setUpTestProject.mjs index a06c43f3c393..e8eb2da6a80b 100644 --- a/.github/actions/set-up-test-project/setUpTestProject.mjs +++ b/.github/actions/set-up-test-project/setUpTestProject.mjs @@ -25,8 +25,12 @@ core.setOutput('test-project-path', TEST_PROJECT_PATH) const bundler = core.getInput('bundler') +const canary = core.getInput('canary') === 'true' + + console.log({ bundler, + canary }) console.log() @@ -34,7 +38,7 @@ console.log() const { dependenciesKey, distKey -} = await createCacheKeys({ baseKeyPrefix: 'test-project', distKeyPrefix: bundler }) +} = await createCacheKeys({ baseKeyPrefix: 'test-project', distKeyPrefix: bundler, canary }) /** * @returns {Promise} @@ -54,7 +58,9 @@ async function main() { await sharedTasks() } else { console.log(`Cache not found for input keys: ${distKey}, ${dependenciesKey}`) - await setUpTestProject() + await setUpTestProject({ + canary: true + }) } await cache.saveCache([TEST_PROJECT_PATH], distKey) @@ -62,9 +68,10 @@ async function main() { } /** + * *@param {{canary: boolean}} options * @returns {Promise} */ -async function setUpTestProject() { +async function setUpTestProject({ canary }) { const TEST_PROJECT_FIXTURE_PATH = path.join( REDWOOD_FRAMEWORK_PATH, '__fixtures__', @@ -83,6 +90,12 @@ async function setUpTestProject() { await execInProject('yarn install') console.log() + if (canary) { + console.log(`Upgrading project to canary`) + await execInProject('yarn rw upgrade -t canary') + console.log() + } + await cache.saveCache([TEST_PROJECT_PATH], dependenciesKey) console.log(`Cache saved with key: ${dependenciesKey}`) diff --git a/.github/actions/ssr_related_changes/action.yml b/.github/actions/ssr_related_changes/action.yml new file mode 100644 index 000000000000..47a1122f2142 --- /dev/null +++ b/.github/actions/ssr_related_changes/action.yml @@ -0,0 +1,8 @@ +name: Streaming-SSR Related Changes +description: Determines if the PR makes any changes related to SSR or streaming +outputs: + rsc-related-changes: + description: If the PR makes any SSR related changes +runs: + using: node20 + main: ssr_related_changes.mjs diff --git a/.github/actions/ssr_related_changes/package.json b/.github/actions/ssr_related_changes/package.json new file mode 100644 index 000000000000..2b643b34dd1e --- /dev/null +++ b/.github/actions/ssr_related_changes/package.json @@ -0,0 +1,9 @@ +{ + "name": "ssr_related_changes", + "private": true, + "dependencies": { + "@actions/core": "1.10.0", + "@actions/exec": "1.1.1" + }, + "packageManager": "yarn@3.6.3" +} diff --git a/.github/actions/ssr_related_changes/ssr_related_changes.mjs b/.github/actions/ssr_related_changes/ssr_related_changes.mjs new file mode 100644 index 000000000000..b2d597b4b848 --- /dev/null +++ b/.github/actions/ssr_related_changes/ssr_related_changes.mjs @@ -0,0 +1,43 @@ +import core from '@actions/core' +import { exec, getExecOutput } from '@actions/exec' + +async function main() { + const branch = process.env.GITHUB_BASE_REF + + // If there is no branch, we're not in a pull request + if (!branch) { + core.setOutput('ssr-related-changes', false) + return + } + + await exec(`git fetch origin ${branch}`) + + const { stdout } = await getExecOutput( + `git diff origin/${branch} --name-only` + ) + + const changedFiles = stdout.toString().trim().split('\n').filter(Boolean) + + for (const changedFile of changedFiles) { + console.log('changedFile', changedFile) + + if ( + changedFile.startsWith('tasks/smoke-tests/streaming-ssr') || + changedFile.startsWith('tasks/smoke-tests/basePlaywright.config.ts') || + changedFile.startsWith('github/actions/ssr_related_changes/') || + changedFile.startsWith('packages/internal/') || + changedFile.startsWith('packages/project-config/') || + changedFile.startsWith('packages/web/') || + changedFile.startsWith('packages/router/') || + changedFile.startsWith('packages/web-server/') || + changedFile.startsWith('packages/vite/') + ) { + core.setOutput('ssr-related-changes', true) + return + } + } + + core.setOutput('ssr-related-changes', false) +} + +main() diff --git a/.github/actions/ssr_related_changes/yarn.lock b/.github/actions/ssr_related_changes/yarn.lock new file mode 100644 index 000000000000..b44c78774462 --- /dev/null +++ b/.github/actions/ssr_related_changes/yarn.lock @@ -0,0 +1,66 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 6 + cacheKey: 8c0 + +"@actions/core@npm:1.10.0": + version: 1.10.0 + resolution: "@actions/core@npm:1.10.0" + dependencies: + "@actions/http-client": ^2.0.1 + uuid: ^8.3.2 + checksum: 9214d1e0cf5cf2a5d48b8f3b12488c6be9f6722ea60f2397409226e8410b5a3e12e558d9b66c93469d180399865ec20180119408a1770f026bd9ecac6965fcda + languageName: node + linkType: hard + +"@actions/exec@npm:1.1.1": + version: 1.1.1 + resolution: "@actions/exec@npm:1.1.1" + dependencies: + "@actions/io": ^1.0.1 + checksum: 4a09f6bdbe50ce68b5cf8a7254d176230d6a74bccf6ecc3857feee209a8c950ba9adec87cc5ecceb04110182d1c17117234e45557d72fde6229b7fd3a395322a + languageName: node + linkType: hard + +"@actions/http-client@npm:^2.0.1": + version: 2.0.1 + resolution: "@actions/http-client@npm:2.0.1" + dependencies: + tunnel: ^0.0.6 + checksum: b58987ba2f53d7988f612ede7ff834573a3360c21f8fdea9fea92f26ada0fd0efafb22aa7d83f49c18965a5b765775d5253e2edb8d9476d924c4b304ef726b67 + languageName: node + linkType: hard + +"@actions/io@npm:^1.0.1": + version: 1.1.2 + resolution: "@actions/io@npm:1.1.2" + checksum: 61c871bbee1cf58f57917d9bb2cf6bb7ea4dc40de3f65c7fb4ec619ceff57fc98f56be9cca2d476b09e7a96e1cba0d88cd125c4f690d384b9483935186f256c1 + languageName: node + linkType: hard + +"ssr_related_changes@workspace:.": + version: 0.0.0-use.local + resolution: "ssr_related_changes@workspace:." + dependencies: + "@actions/core": 1.10.0 + "@actions/exec": 1.1.1 + languageName: unknown + linkType: soft + +"tunnel@npm:^0.0.6": + version: 0.0.6 + resolution: "tunnel@npm:0.0.6" + checksum: e27e7e896f2426c1c747325b5f54efebc1a004647d853fad892b46d64e37591ccd0b97439470795e5262b5c0748d22beb4489a04a0a448029636670bfd801b75 + languageName: node + linkType: hard + +"uuid@npm:^8.3.2": + version: 8.3.2 + resolution: "uuid@npm:8.3.2" + bin: + uuid: dist/bin/uuid + checksum: bcbb807a917d374a49f475fae2e87fdca7da5e5530820ef53f65ba1d12131bd81a92ecf259cc7ce317cbe0f289e7d79fdfebcef9bfa3087c8c8a2fa304c9be54 + languageName: node + linkType: hard diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e809bd381b3c..8dbddbb7f4cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,31 @@ jobs: id: rsc-related-changes uses: ./.github/actions/rsc_related_changes + ssr-related-changes: + needs: check + if: github.repository == 'redwoodjs/redwood' + name: 🌤️ SSR related changes? + runs-on: ubuntu-latest + outputs: + ssr-related-changes: ${{ steps.ssr-related-changes.outputs.ssr-related-changes }} + steps: + - uses: actions/checkout@v3 + + - name: ⬢ Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: 🐈 Yarn install + working-directory: ./.github/actions/ssr_related_changes + run: yarn install --inline-builds + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: 🌤️ SSR related changes? + id: ssr-related-changes + uses: ./.github/actions/ssr_related_changes + check: needs: only-doc-changes if: needs.only-doc-changes.outputs.only-doc-changes == 'false' @@ -232,6 +257,80 @@ jobs: steps: - run: echo "Only doc changes" + ssr-smoke-tests: + needs: ssr-related-changes + if: needs.ssr-related-changes.outputs.ssr-related-changes == 'true' + + strategy: + matrix: + os: [ubuntu-latest] + # [ubuntu-latest, windows-latest] disabled, because windows misbehaving + # waiting for help from main-man Josh + + name: 🔁 SSR Smoke tests / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + env: + REDWOOD_CI: 1 + REDWOOD_VERBOSE_TELEMETRY: 1 + + steps: + - uses: actions/checkout@v3 + + - 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 + with: + bundler: vite + canary: true + env: + REDWOOD_DISABLE_TELEMETRY: 1 + YARN_ENABLE_IMMUTABLE_INSTALLS: false + + - name: Run SSR codemods on test project + run: ./tasks/test-project/convert-to-ssr-fixture ${{ steps.set-up-test-project.outputs.test-project-path }} + env: + REDWOOD_DISABLE_TELEMETRY: 1 + + - name: 🎭 Install playwright dependencies + run: npx playwright install --with-deps chromium + + - name: Run SSR [DEV] smoke tests + working-directory: ./tasks/smoke-tests/streaming-ssr-dev + run: npx playwright test + env: + REDWOOD_TEST_PROJECT_PATH: '${{ steps.set-up-test-project.outputs.test-project-path }}' + REDWOOD_DISABLE_TELEMETRY: 1 + + - name: Build for production + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} + run: yarn rw build --no-prerender + env: + REDWOOD_DISABLE_TELEMETRY: 1 + + - name: Run SSR [PROD] smoke tests + working-directory: ./tasks/smoke-tests/streaming-ssr-prod + run: npx playwright test + env: + REDWOOD_TEST_PROJECT_PATH: '${{ steps.set-up-test-project.outputs.test-project-path }}' + REDWOOD_DISABLE_TELEMETRY: 1 + smoke-tests: needs: check diff --git a/.yarnrc.yml b/.yarnrc.yml index a46601216899..e57c81ecfa7a 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -17,3 +17,4 @@ plugins: preferInteractive: true yarnPath: .yarn/releases/yarn-3.6.3.cjs + diff --git a/packages/cli/src/commands/experimental/setupStreamingSsrHandler.js b/packages/cli/src/commands/experimental/setupStreamingSsrHandler.js index 007e1bdf7b99..9c431b5a41f7 100644 --- a/packages/cli/src/commands/experimental/setupStreamingSsrHandler.js +++ b/packages/cli/src/commands/experimental/setupStreamingSsrHandler.js @@ -3,6 +3,7 @@ import path from 'path' import { Listr } from 'listr2' +import { addWebPackages } from '@redwoodjs/cli-helpers' import { getConfigPath } from '@redwoodjs/project-config' import { errorTelemetry } from '@redwoodjs/telemetry' @@ -158,6 +159,9 @@ export const handler = async ({ force, verbose }) => { }) }, }, + addWebPackages([ + '@apollo/experimental-nextjs-app-support@0.0.0-commit-b8a73fe', + ]), { task: () => { printTaskEpilogue(command, description, EXPERIMENTAL_TOPIC_ID) diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index 6803a9a813a2..f394a266674e 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -10,6 +10,7 @@ import { getConfig, getPaths } from '@redwoodjs/project-config' import handleJsAsJsx from './plugins/vite-plugin-jsx-loader' import removeFromBundle from './plugins/vite-plugin-remove-from-bundle' +import swapApolloProvider from './plugins/vite-plugin-swap-apollo-provider' /** * Pre-configured vite plugin, with required config for Redwood apps. @@ -261,6 +262,8 @@ export default function redwoodPluginVite(): PluginOption[] { } }, }, + // We can remove when streaming is stable + rwConfig.experimental.streamingSsr.enabled && swapApolloProvider(), // ----------------- handleJsAsJsx(), // Remove the splash-page from the bundle. diff --git a/packages/vite/src/plugins/__tests__/swap-apollo-provider.test.mts b/packages/vite/src/plugins/__tests__/swap-apollo-provider.test.mts new file mode 100644 index 000000000000..35f352a1e8fc --- /dev/null +++ b/packages/vite/src/plugins/__tests__/swap-apollo-provider.test.mts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import plugin from '../vite-plugin-swap-apollo-provider.js' + +// @ts-expect-error node test not configured correctly +const swapApolloProvider = plugin.default + + +describe('excludeModule', () => { + it('should swap the import', async() => { + const plugin = swapApolloProvider() + + const output = await plugin.transform(`import ApolloProvider from '@redwoodjs/web/apollo'`, '/Users/dac09/Experiments/ssr-2354/web/src/App.tsx') + + assert.strictEqual(output, "import ApolloProvider from '@redwoodjs/web/dist/apollo/suspense'") +}) +}) diff --git a/packages/vite/src/plugins/vite-plugin-swap-apollo-provider.ts b/packages/vite/src/plugins/vite-plugin-swap-apollo-provider.ts new file mode 100644 index 000000000000..8f3742492b4c --- /dev/null +++ b/packages/vite/src/plugins/vite-plugin-swap-apollo-provider.ts @@ -0,0 +1,26 @@ +import type { PluginOption } from 'vite' + +/** + * + * Temporary plugin, that swaps the ApolloProvider import with the Suspense enabled one, + * until it becomes stable. + * + * import { RedwoodApolloProvider } from "@redwoodjs/web/apollo" -> + * import { RedwoodApolloProvider } from "@redwoodjs/web/dist/apollo/suspense" + * + */ +export default function swapApolloProvider(): PluginOption { + return { + name: 'redwood-swap-apollo-provider', + async transform(code: string, id: string) { + if (/web\/src\/App\.(ts|tsx|js|jsx)$/.test(id)) { + return code.replace( + '@redwoodjs/web/apollo', + '@redwoodjs/web/dist/apollo/suspense' + ) + } + + return code + }, + } +} diff --git a/packages/vite/src/streaming/registerGlobals.ts b/packages/vite/src/streaming/registerGlobals.ts index 1ace576f59b2..75d034636579 100644 --- a/packages/vite/src/streaming/registerGlobals.ts +++ b/packages/vite/src/streaming/registerGlobals.ts @@ -33,11 +33,11 @@ export const registerFwGlobals = () => { if (/^[a-zA-Z][a-zA-Z\d+\-.]*?:/.test(apiPath)) { return apiPath } else { - const proxiedApiUrl = - // NOTE: rwConfig.web.host defaults to "localhost", which is - // troublesome in regards to IPv6/IPv4. So all the more - // reason to set RWJS_EXP_SSR_GRAPHQL_ENDPOINT + // NOTE: rwConfig.web.host defaults to "localhost", which is + // When running in production, the api server does not listen on localhost + const proxiedApiUrl = swapLocalhostFor127( 'http://' + rwConfig.web.host + ':' + rwConfig.web.port + apiPath + ) if ( process.env.NODE_ENV === 'production' && @@ -47,9 +47,7 @@ export const registerFwGlobals = () => { console.warn() console.warn() - console.warn( - `You haven't configured your API absolute url. Localhost is unlikely to work in production` - ) + console.warn(`You haven't configured your API absolute url.`) console.warn(`Using ${proxiedApiUrl}`) console.warn() @@ -64,7 +62,7 @@ export const registerFwGlobals = () => { return proxiedApiUrl } - return ( + return swapLocalhostFor127( (process.env.RWJS_EXP_SSR_GRAPHQL_ENDPOINT as string) ?? proxiedApiUrl ) } @@ -76,3 +74,7 @@ export const registerFwGlobals = () => { REDWOOD_ENV_EDITOR: JSON.stringify(process.env.REDWOOD_ENV_EDITOR), } } + +function swapLocalhostFor127(hostString: string) { + return hostString.replace('localhost', '127.0.0.1') +} diff --git a/tasks/smoke-tests/auth/tests/authChecks.spec.ts b/tasks/smoke-tests/auth/tests/authChecks.spec.ts index d5b8fa1c5632..b20171bbe277 100644 --- a/tasks/smoke-tests/auth/tests/authChecks.spec.ts +++ b/tasks/smoke-tests/auth/tests/authChecks.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test' -import { loginAsTestUser, signUpTestUser } from '../../common' +import { loginAsTestUser, signUpTestUser } from '../../shared/common' // Signs up a user before these tests diff --git a/tasks/smoke-tests/auth/tests/rbacChecks.spec.ts b/tasks/smoke-tests/auth/tests/rbacChecks.spec.ts index 216c49e97f48..280c2ad4c820 100644 --- a/tasks/smoke-tests/auth/tests/rbacChecks.spec.ts +++ b/tasks/smoke-tests/auth/tests/rbacChecks.spec.ts @@ -3,10 +3,9 @@ import * as path from 'node:path' import { test, expect } from '@playwright/test' import type { PlaywrightTestArgs, Page } from '@playwright/test' -// @ts-expect-error - With `* as` you have to use .default() when calling execa import execa from 'execa' -import { loginAsTestUser, signUpTestUser } from '../../common' +import { loginAsTestUser, signUpTestUser } from '../../shared/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 diff --git a/tasks/smoke-tests/dev/tests/dev.spec.ts b/tasks/smoke-tests/dev/tests/dev.spec.ts index c36dd405d595..17a5e003c9ef 100644 --- a/tasks/smoke-tests/dev/tests/dev.spec.ts +++ b/tasks/smoke-tests/dev/tests/dev.spec.ts @@ -1,5 +1,5 @@ import { test } from '@playwright/test' -import { smokeTest } from '../../common' +import { smokeTest } from '../../shared/common' test('Smoke test with dev server', smokeTest) diff --git a/tasks/smoke-tests/prerender/tests/prerender.spec.ts b/tasks/smoke-tests/prerender/tests/prerender.spec.ts index 77557ec35613..4dd89b0e32c5 100644 --- a/tasks/smoke-tests/prerender/tests/prerender.spec.ts +++ b/tasks/smoke-tests/prerender/tests/prerender.spec.ts @@ -7,9 +7,10 @@ import type { PlaywrightTestArgs, PlaywrightWorkerArgs, } from '@playwright/test' -// @ts-expect-error - With `* as` you have to use .default() when calling execa import execa from 'execa' +import { checkHomePageCellRender } from '../../shared/homePage' + let noJsBrowser: BrowserContext test.beforeAll(async ({ browser }: PlaywrightWorkerArgs) => { @@ -22,21 +23,7 @@ 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', - ]) + checkHomePageCellRender(pageWithoutJs) pageWithoutJs.close() }) diff --git a/tasks/smoke-tests/serve/tests/serve.spec.ts b/tasks/smoke-tests/serve/tests/serve.spec.ts index 08fbb4cd3c82..715ee04b6c1d 100644 --- a/tasks/smoke-tests/serve/tests/serve.spec.ts +++ b/tasks/smoke-tests/serve/tests/serve.spec.ts @@ -1,5 +1,5 @@ import { test } from '@playwright/test' -import { smokeTest } from '../../common' +import { smokeTest } from '../../shared/common' test('Smoke test with rw serve', smokeTest) diff --git a/tasks/smoke-tests/common.ts b/tasks/smoke-tests/shared/common.ts similarity index 100% rename from tasks/smoke-tests/common.ts rename to tasks/smoke-tests/shared/common.ts diff --git a/tasks/smoke-tests/shared/delayedPage.ts b/tasks/smoke-tests/shared/delayedPage.ts new file mode 100644 index 000000000000..4b52aaf7c85f --- /dev/null +++ b/tasks/smoke-tests/shared/delayedPage.ts @@ -0,0 +1,47 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +export async function checkDelayedPageRendering( + page: Page, + { expectedDelay }: { expectedDelay: number } +) { + const delayedLogStatements: { message: string; time: number }[] = [] + + page.on('console', (message) => { + if (message.type() === 'log') { + const messageText = message.text() + + if (messageText.includes('delayed by')) { + delayedLogStatements.push({ + message: messageText, + time: Date.now(), + }) + } + } + }) + + await page.goto('/delayed') + + expect(delayedLogStatements.length).toBe(4) + + delayedLogStatements.forEach((log, index) => { + if (index > 0) { + const timeDiff = log.time - delayedLogStatements[index - 1].time + // If we're not expecting a delay + // Check that timeDiff is less than 300ms (with margin of error) + if (expectedDelay === 0) { + expect(timeDiff).toBeLessThan(300) + } else { + // Allow a 300ms margin of error + expect(timeDiff).toBeGreaterThan(expectedDelay - 300) + expect(timeDiff).toBeLessThan(expectedDelay + 300) + } + } + }) + + // Check that its actually rendered on the page. Important when **not** progressively rendering + await expect(page.locator('[data-test-id="delayed-text-1"]')).toHaveCount(1) + await expect(page.locator('[data-test-id="delayed-text-2"]')).toHaveCount(1) + await expect(page.locator('[data-test-id="delayed-text-3"]')).toHaveCount(1) + await expect(page.locator('[data-test-id="delayed-text-4"]')).toHaveCount(1) +} diff --git a/tasks/smoke-tests/shared/homePage.ts b/tasks/smoke-tests/shared/homePage.ts new file mode 100644 index 000000000000..dc442192999c --- /dev/null +++ b/tasks/smoke-tests/shared/homePage.ts @@ -0,0 +1,21 @@ +import { expect } from '@playwright/test' +import type { Page } from '@playwright/test' + +export async function checkHomePageCellRender(page: Page) { + const cellSuccessState = await page.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 page.locator('header >> h1').innerText() + expect(navTitle).toBe('Redwood Blog') + + const navLinks = await page.locator('nav >> ul').innerText() + expect(navLinks.split('\n')).toEqual([ + 'About', + 'Contact Us', + 'Admin', + 'Log In', + ]) +} diff --git a/tasks/smoke-tests/smoke-tests.mjs b/tasks/smoke-tests/smoke-tests.mjs index 0bfb1db7c46f..fddbe1b0c708 100644 --- a/tasks/smoke-tests/smoke-tests.mjs +++ b/tasks/smoke-tests/smoke-tests.mjs @@ -64,7 +64,7 @@ async function main() { .readdirSync(path.dirname(fileURLToPath(import.meta.url)), { withFileTypes: true, }) - .filter((dirent) => dirent.isDirectory()) + .filter((dirent) => dirent.isDirectory() && dirent.name !== 'shared') .map((dirent) => dirent.name) if (smokeTest === undefined) { diff --git a/tasks/smoke-tests/streaming-ssr-dev/playwright.config.ts b/tasks/smoke-tests/streaming-ssr-dev/playwright.config.ts new file mode 100644 index 000000000000..b188c0d86434 --- /dev/null +++ b/tasks/smoke-tests/streaming-ssr-dev/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', + // headless: false, + }, + + // Run your local dev server before starting the tests + webServer: { + command: 'yarn redwood dev --no-generate --fwd="--no-open"', + cwd: process.env.REDWOOD_TEST_PROJECT_PATH, + url: 'http://localhost:8910', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, +}) diff --git a/tasks/smoke-tests/streaming-ssr-dev/tests/progressiveRendering.spec.ts b/tasks/smoke-tests/streaming-ssr-dev/tests/progressiveRendering.spec.ts new file mode 100644 index 000000000000..0f630a02bae9 --- /dev/null +++ b/tasks/smoke-tests/streaming-ssr-dev/tests/progressiveRendering.spec.ts @@ -0,0 +1,85 @@ +import { setTimeout } from 'node:timers/promises' + +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' + +import { checkHomePageCellRender } from '../../shared/homePage' + +let pageWithClientBlocked: Page + +test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + + // Disable loading of client-side JS + await page.route('**/entry.client.{js,tsx,ts,jsx}', (route) => route.abort()) + await page.route('**/App.{js,tsx,ts,jsx}', (route) => route.abort()) + await page.route('**/index.*.{js,tsx,ts,jsx}', (route) => route.abort()) + + pageWithClientBlocked = page +}) + +test.afterAll(() => { + pageWithClientBlocked.close() +}) + +test('Check that homepage has content rendered from the server (progressively)', async () => { + await pageWithClientBlocked.goto('/') + + // @NOTE: It shows loading when the fetch fails, so client side can recover. + const apiServerLoading = pageWithClientBlocked.getByText('Loading...') + + while (await apiServerLoading.isVisible()) { + await pageWithClientBlocked.reload() + await setTimeout(500) + } + + // Appears when Cell is successfully rendered + await pageWithClientBlocked.waitForSelector('article') + + await checkHomePageCellRender(pageWithClientBlocked) +}) + +test('Check delayed page has content progressively rendered', async () => { + const delayedLogStatements: { message: string; time: number }[] = [] + + pageWithClientBlocked.on('console', (message) => { + if (message.type() === 'log') { + const messageText = message.text() + + if (messageText.includes('delayed by')) { + delayedLogStatements.push({ + message: messageText, + time: Date.now(), + }) + } + } + }) + + await pageWithClientBlocked.goto('/delayed') + + expect(delayedLogStatements.length).toBe(4) + + delayedLogStatements.forEach((log, index) => { + if (index > 0) { + const timeDiff = log.time - delayedLogStatements[index - 1].time + + // With room for error, approximately 1 second + expect(timeDiff).toBeGreaterThan(600) + expect(timeDiff).toBeLessThan(1400) + } + }) + + // Check that its actually rendered on the page. Important when **not** progressively rendering + await expect( + pageWithClientBlocked.locator('[data-test-id="delayed-text-1"]') + ).toHaveCount(1) + await expect( + pageWithClientBlocked.locator('[data-test-id="delayed-text-2"]') + ).toHaveCount(1) + await expect( + pageWithClientBlocked.locator('[data-test-id="delayed-text-3"]') + ).toHaveCount(1) + await expect( + pageWithClientBlocked.locator('[data-test-id="delayed-text-4"]') + ).toHaveCount(1) +}) diff --git a/tasks/smoke-tests/streaming-ssr-prod/playwright.config.ts b/tasks/smoke-tests/streaming-ssr-prod/playwright.config.ts new file mode 100644 index 000000000000..29c14c8da4a3 --- /dev/null +++ b/tasks/smoke-tests/streaming-ssr-prod/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', + // headless: false, + }, + + // Run your local dev server before starting the tests + webServer: { + command: 'yarn redwood serve', + cwd: process.env.REDWOOD_TEST_PROJECT_PATH, + url: 'http://localhost:8910', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, +}) diff --git a/tasks/smoke-tests/streaming-ssr-prod/tests/botRendering.spec.ts b/tasks/smoke-tests/streaming-ssr-prod/tests/botRendering.spec.ts new file mode 100644 index 000000000000..439db2806448 --- /dev/null +++ b/tasks/smoke-tests/streaming-ssr-prod/tests/botRendering.spec.ts @@ -0,0 +1,42 @@ +import type { Page } from '@playwright/test' +import { test } from '@playwright/test' + +import { checkDelayedPageRendering } from '../../shared/delayedPage' +import { checkHomePageCellRender } from '../../shared/homePage' + +let botPageNoJs: Page + +test.beforeAll(async ({ browser }) => { + // UA taken from https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers + const botContext = await browser.newContext({ + userAgent: + 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/W.X.Y.Z Safari/537.36', + // @@MARK TODO awaiting react team feedback. I dont understand why React is still injecting JS instead of giving us + // a fully formed HTML page + // javaScriptEnabled: false, + }) + + const botPage = await botContext.newPage() + await botPage.route('**/*.*.{js,tsx,ts,jsx}', (route) => route.abort()) + + botPageNoJs = botPage +}) + +test.afterAll(() => { + botPageNoJs.close() +}) + +test('Check that homepage has content rendered from the server', async () => { + await botPageNoJs.goto('/') + + // Appears when Cell is successfully rendered + await botPageNoJs.waitForSelector('article') + + await checkHomePageCellRender(botPageNoJs) +}) + +test('Check delayed page is NOT progressively rendered', async () => { + await checkDelayedPageRendering(botPageNoJs, { + expectedDelay: 0, + }) +}) diff --git a/tasks/smoke-tests/streaming-ssr-prod/tests/progressiveRendering.spec.ts b/tasks/smoke-tests/streaming-ssr-prod/tests/progressiveRendering.spec.ts new file mode 100644 index 000000000000..f95495dc915c --- /dev/null +++ b/tasks/smoke-tests/streaming-ssr-prod/tests/progressiveRendering.spec.ts @@ -0,0 +1,37 @@ +import type { Page } from '@playwright/test' +import { test } from '@playwright/test' + +import { checkDelayedPageRendering } from '../../shared/delayedPage' +import { checkHomePageCellRender } from '../../shared/homePage' + +let pageWithClientBlocked: Page + +test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + + // Disable loading of client-side JS + // Note that we don't want to disable JS entirely, because progressive rendering + // requires JS injected in