diff --git a/.github/actions/actionsLib.mjs b/.github/actions/actionsLib.mjs index cee1a00f2606..8702ffde13d9 100644 --- a/.github/actions/actionsLib.mjs +++ b/.github/actions/actionsLib.mjs @@ -22,7 +22,6 @@ function execWithEnv(command, { env = {}, ...rest } = {}) { command, undefined, { - // @ts-expect-error TS doesn't like spreading process.env here but it's fine for our purposes. env: { ...process.env, ...env @@ -68,7 +67,6 @@ export async function createCacheKeys({ baseKeyPrefix, distKeyPrefix }) { const baseKey = [ baseKeyPrefix, process.env.RUNNER_OS, - // @ts-expect-error not sure how to change the lib compiler option to es2021+ here. process.env.GITHUB_REF.replaceAll('/', '-'), await hashFiles(path.join('__fixtures__', 'test-project')) ].join('-') diff --git a/.github/actions/rsc_related_changes/action.yml b/.github/actions/rsc_related_changes/action.yml new file mode 100644 index 000000000000..43cba17a9774 --- /dev/null +++ b/.github/actions/rsc_related_changes/action.yml @@ -0,0 +1,9 @@ +name: RSC Related Changes +description: Determines if the PR makes any changes related to RSCs +outputs: + rsc-related-changes: + description: If the PR makes any RSC related changes +runs: + # `node18` isn't supported yet + using: node16 + main: rsc_related_changes.mjs diff --git a/.github/actions/rsc_related_changes/package.json b/.github/actions/rsc_related_changes/package.json new file mode 100644 index 000000000000..84293a6a3295 --- /dev/null +++ b/.github/actions/rsc_related_changes/package.json @@ -0,0 +1,9 @@ +{ + "name": "only_doc_changes", + "private": true, + "dependencies": { + "@actions/core": "1.10.0", + "@actions/exec": "1.1.1" + }, + "packageManager": "yarn@3.6.3" +} diff --git a/.github/actions/rsc_related_changes/rsc_related_changes.mjs b/.github/actions/rsc_related_changes/rsc_related_changes.mjs new file mode 100644 index 000000000000..4077b1d9b98f --- /dev/null +++ b/.github/actions/rsc_related_changes/rsc_related_changes.mjs @@ -0,0 +1,46 @@ +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('rsc-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) + + // As the RSC implementation changes, this list will need to be updated. + // Also, I could be much more specific here, but then I'd also have to + // update this list much more often. So this'll serve as a good enough + // starting point. + if ( + changedFile.startsWith('tasks/smoke-tests/rsc/') || + changedFile.startsWith('tasks/smoke-tests/basePlaywright.config.ts') || + changedFile.startsWith('.github/actions/set-up-rsc-project/') || + changedFile.startsWith('github/actions/rsc_related_changes/') || + changedFile.startsWith('packages/internal/') || + changedFile.startsWith('packages/project-config/') || + changedFile.startsWith('packages/web/') || + changedFile.startsWith('packages/vite/') + ) { + core.setOutput('rsc-related-changes', true) + return + } + } + + core.setOutput('rsc-related-changes', false) +} + +main() diff --git a/.github/actions/rsc_related_changes/yarn.lock b/.github/actions/rsc_related_changes/yarn.lock new file mode 100644 index 000000000000..527838bbdcb8 --- /dev/null +++ b/.github/actions/rsc_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 + +"only_doc_changes@workspace:.": + version: 0.0.0-use.local + resolution: "only_doc_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/actions/set-up-rsc-project/README.md b/.github/actions/set-up-rsc-project/README.md new file mode 100644 index 000000000000..4b01027d9cee --- /dev/null +++ b/.github/actions/set-up-rsc-project/README.md @@ -0,0 +1,32 @@ +# GitHub action to create a RW project with RSCs set up + +This action creates a RW project with Streaming SSR and RSC support set up. +It's used for RSC smoke tests. + +It runs `npx -y create-redwood-app@canary ...` to set the project up with the +latest canary release of Redwood. It then runs +`experimental setup-streaming-ssr` and `experimental setup-rsc` followed by +a build of the rw app. Finally it runs `project:copy` to get the latest +changes to the framework (i.e. the changes introduced by the PR triggering this +action) into the project. + +## Testing/running locally + +Go into the github actions folder +`cd .github/actions` + +Then run the following command to execute the action +`node set-up-rsc-project/setUpRscProjectLocally.mjs` + +## Design + +The main logic of the action is in the `setUpRscProject.mjs` file. To be able +to run that code both on GitHub and locally it uses dependency injection. The +injection is done by `setupRscProjectLocally.mjs` for when you want to run +the action on your own machine and by `setupRscProjectGitHib.mjs` when it's +triggered by GitHub CI. + +When doing further changes to the code here it's very important to keep the +DI scripts as light on logic as possible. Ideally all logic is kept to +`setUpRscProject.mjs` so that the same logic is used both locally and on +GitHub. diff --git a/.github/actions/set-up-rsc-project/action.yaml b/.github/actions/set-up-rsc-project/action.yaml new file mode 100644 index 000000000000..858e5937be56 --- /dev/null +++ b/.github/actions/set-up-rsc-project/action.yaml @@ -0,0 +1,10 @@ +name: Set up RSC test project +description: Sets up an RSC project for smoke-tests + +runs: + using: node20 + main: 'setUpRscProjectGitHub.mjs' + +outputs: + test-project-path: + description: Path to the test project diff --git a/.github/actions/set-up-rsc-project/jsconfig.json b/.github/actions/set-up-rsc-project/jsconfig.json new file mode 100644 index 000000000000..8effcfaa09ef --- /dev/null +++ b/.github/actions/set-up-rsc-project/jsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "noEmit": true, + "esModuleInterop": true, + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "skipLibCheck": false, + "jsx": "react-jsx" + }, +} diff --git a/.github/actions/set-up-rsc-project/package.json b/.github/actions/set-up-rsc-project/package.json new file mode 100644 index 000000000000..90aa7b8a79bd --- /dev/null +++ b/.github/actions/set-up-rsc-project/package.json @@ -0,0 +1,6 @@ +{ + "name": "set-up-rsc-project", + "version": "0.0.0", + "private": true, + "type": "module" +} diff --git a/.github/actions/set-up-rsc-project/setUpRscProject.mjs b/.github/actions/set-up-rsc-project/setUpRscProject.mjs new file mode 100644 index 000000000000..625da3cc3ddb --- /dev/null +++ b/.github/actions/set-up-rsc-project/setUpRscProject.mjs @@ -0,0 +1,105 @@ +/* eslint-env node */ +// @ts-check + +import path from 'node:path' + +import { REDWOOD_FRAMEWORK_PATH } from '../actionsLib.mjs' + +/** + * @typedef {import('@actions/exec').ExecOptions} ExecOptions + */ + +/** + * Exec a command. + * Output will be streamed to the live console. + * Returns promise with return code + * + * @callback Exec + * @param {string} commandLine command to execute (can include additional args). Must be correctly escaped. + * @param {string[]=} args arguments for tool. Escaping is handled by the lib. + * @param {ExecOptions=} options exec options. See ExecOptions + * @returns {Promise} exit code + */ + +/** + * @callback ExecInProject + * @param {string} commandLine command to execute (can include additional args). Must be correctly escaped. + * @param {Omit=} options exec options. See ExecOptions + * @returns {Promise} exit code + */ + +/** + * @param {string} rscProjectPath + * @param {Object} core + * @param {(key: string, value: string) => void} core.setOutput + * @param {Exec} exec + * @param {ExecInProject} execInProject + * @returns {Promise} + */ +export async function main( + rscProjectPath, + core, + exec, + execInProject +) { + core.setOutput('rsc-project-path', rscProjectPath) + + console.log('rwPath', REDWOOD_FRAMEWORK_PATH) + console.log('rscProjectPath', rscProjectPath) + + await setUpRscProject( + rscProjectPath, + exec, + execInProject, + ) +} + +/** + * @param {string} rscProjectPath + * @param {Exec} exec + * @param {ExecInProject} execInProject + * @returns {Promise} + */ +async function setUpRscProject( + rscProjectPath, + exec, + execInProject, +) { + const rwBinPath = path.join( + REDWOOD_FRAMEWORK_PATH, + 'packages/cli/dist/index.js' + ) + const rwfwBinPath = path.join( + REDWOOD_FRAMEWORK_PATH, + 'packages/cli/dist/rwfw.js' + ) + + console.log(`Creating project at ${rscProjectPath}`) + console.log() + await exec('npx', [ + '-y', + 'create-redwood-app@canary', + '-y', + '--no-git', + rscProjectPath, + ]) + + console.log(`Setting up Streaming/SSR in ${rscProjectPath}`) + const cmdSetupStreamingSSR = `node ${rwBinPath} experimental setup-streaming-ssr -f` + await execInProject(cmdSetupStreamingSSR) + console.log() + + console.log(`Setting up RSC in ${rscProjectPath}`) + await execInProject(`node ${rwBinPath} experimental setup-rsc`) + console.log() + + console.log(`Building project in ${rscProjectPath}`) + await execInProject(`node ${rwBinPath} build -v`) + console.log() + + console.log(`Building project in ${rscProjectPath}`) + await execInProject(`node ${rwfwBinPath} project:copy`, { + env: { RWFW_PATH: REDWOOD_FRAMEWORK_PATH }, + }) + console.log() +} diff --git a/.github/actions/set-up-rsc-project/setUpRscProjectGitHub.mjs b/.github/actions/set-up-rsc-project/setUpRscProjectGitHub.mjs new file mode 100644 index 000000000000..1ba4dd3ac6fb --- /dev/null +++ b/.github/actions/set-up-rsc-project/setUpRscProjectGitHub.mjs @@ -0,0 +1,17 @@ +/* eslint-env node */ +// @ts-check + +import path from 'node:path' + +import core from '@actions/core' +import { exec } from '@actions/exec' + +import { createExecWithEnvInCwd } from '../actionsLib.mjs' + +import { main } from './setUpRscProject.mjs' + +const rscProjectPath = path.join(path.dirname(process.cwd()), 'rsc-project') + +const execInProject = createExecWithEnvInCwd(rscProjectPath) + +main(rscProjectPath, core, exec, execInProject) diff --git a/.github/actions/set-up-rsc-project/setUpRscProjectLocally.mjs b/.github/actions/set-up-rsc-project/setUpRscProjectLocally.mjs new file mode 100644 index 000000000000..5ab951f82775 --- /dev/null +++ b/.github/actions/set-up-rsc-project/setUpRscProjectLocally.mjs @@ -0,0 +1,117 @@ +/* eslint-env node */ +// @ts-check + +import os from 'node:os' +import path from 'node:path' + +import execa from 'execa' + +import { main } from './setUpRscProject.mjs' + +class ExecaError extends Error { + stdout + stderr + exitCode + + constructor({ stdout, stderr, exitCode }) { + super(`execa failed with exit code ${exitCode}`) + this.stdout = stdout + this.stderr = stderr + this.exitCode = exitCode + } +} + +/** + * @template [EncodingType=string] + * @typedef {import('execa').Options} ExecaOptions + */ + +/** + * @typedef {{ + * env?: Record + * }} ExecOptions + */ + +/** + * @param {string} commandLine command to execute (can include additional args). Must be correctly escaped. + * @param {string[]=} args arguments for tool. Escaping is handled by the lib. + * @param {ExecOptions=} options exec options. See ExecOptions + */ +async function exec(commandLine, args, options) { + return execa(commandLine, args, options) + .then(({ stdout, stderr, exitCode }) => { + if (exitCode !== 0) { + throw new ExecaError({ stdout, stderr, exitCode }) + } + }) + .catch((error) => { + if (error instanceof ExecaError) { + // Rethrow ExecaError + throw error + } else { + const { stdout, stderr, exitCode } = error + console.log('error', error) + throw new ExecaError({ stdout, stderr, exitCode }) + } + }) +} + +/** + * @param {string} cwd + * @param {Record=} env + * @returns {ExecaOptions} + */ +function getExecaOptions(cwd, env = {}) { + return { + shell: true, + stdio: 'inherit', + cleanup: true, + cwd, + env, + } +} + +const rscProjectPath = path.join( + os.tmpdir(), + 'rsc-project', + // ":" is problematic with paths + new Date().toISOString().split(':').join('-') +) + +// Mock for @actions/core +const core = { + setOutput: () => {}, +} + +/** + * Exec a command. + * Output will be streamed to the live console. + * Returns promise with return code + * + * @param {string} commandLine command to execute (can include additional args). Must be correctly escaped. + * @param {ExecOptions=} options exec options. See ExecOptions + * @returns {Promise} exit code + */ +function execInProject(commandLine, options) { + return exec( + commandLine, + undefined, + getExecaOptions(rscProjectPath, options?.env) + ) +} + +/** + * Exec a command. + * Output will be streamed to the live console. + * Returns promise with return code + * + * @param {string} commandLine command to execute (can include additional args). Must be correctly escaped. + * @param {string[]=} args arguments for tool. Escaping is handled by the lib. + * @param {ExecOptions=} options exec options. See ExecOptions + * @returns {Promise} exit code + */ +function execInRoot(commandLine, args, options) { + return exec(commandLine, args, getExecaOptions('/', options?.env)) +} + +main(rscProjectPath, core, execInRoot, execInProject) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f11d7ff13da..ed7414923531 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,31 @@ jobs: id: only-doc-changes uses: ./.github/actions/only_doc_changes + rsc-related-changes: + needs: check + if: github.repository == 'redwoodjs/redwood' + name: 🐘 RSC related changes? + runs-on: ubuntu-latest + outputs: + rsc-related-changes: ${{ steps.rsc-related-changes.outputs.rsc-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/rsc_related_changes + run: yarn install --inline-builds + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: 🐘 RSC related changes? + id: rsc-related-changes + uses: ./.github/actions/rsc_related_changes + check: needs: only-doc-changes if: needs.only-doc-changes.outputs.only-doc-changes == 'false' @@ -455,3 +480,68 @@ jobs: runs-on: ${{ matrix.os }} steps: - run: echo "Only doc changes" + + rsc-smoke-tests: + needs: rsc-related-changes + if: needs.rsc-related-changes.outputs.rsc-related-changes == 'true' + + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + + name: 🔄🐘 RSC 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 RSC project + id: set-up-rsc-project + uses: ./.github/actions/set-up-rsc-project + env: + REDWOOD_DISABLE_TELEMETRY: 1 + YARN_ENABLE_IMMUTABLE_INSTALLS: false + + - name: 🎭 Install playwright dependencies + run: npx playwright install --with-deps chromium + + - name: 🐘 Run RSC smoke tests + working-directory: tasks/smoke-tests/rsc + run: npx playwright test + env: + REDWOOD_TEST_PROJECT_PATH: ${{ steps.set-up-rsc-project.outputs.rsc-project-path }} + REDWOOD_DISABLE_TELEMETRY: 1 + + rsc-smoke-tests-mock: + needs: rsc-related-changes + if: needs.rsc-related-changes.outputs.rsc-related-changes != 'true' + + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + + name: 🔄🐘 RSC Smoke tests / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + steps: + - run: echo "RSC smoke tests mock" diff --git a/packages/vite/src/waku-lib/rsc-handler-worker.ts b/packages/vite/src/waku-lib/rsc-handler-worker.ts index e0fef6b20824..aefa68ab11bd 100644 --- a/packages/vite/src/waku-lib/rsc-handler-worker.ts +++ b/packages/vite/src/waku-lib/rsc-handler-worker.ts @@ -267,10 +267,14 @@ export async function setClientEntries( } const baseDir = path.dirname(entriesFile) absoluteClientEntries = Object.fromEntries( - Object.entries(clientEntries).map(([key, val]) => [ - path.join(baseDir, key), - config.base + val, - ]) + Object.entries(clientEntries).map(([key, val]) => { + let fullKey = path.join(baseDir, key) + if (process.platform === 'win32') { + fullKey = fullKey.replaceAll('\\', '/') + } + console.log('fullKey', fullKey, 'value', config.base + val) + return [fullKey, config.base + val] + }) ) console.log( diff --git a/tasks/smoke-tests/basePlaywright.config.ts b/tasks/smoke-tests/basePlaywright.config.ts index 272ca3a308b3..7de831b0328f 100644 --- a/tasks/smoke-tests/basePlaywright.config.ts +++ b/tasks/smoke-tests/basePlaywright.config.ts @@ -1,3 +1,5 @@ +import * as fs from 'node:fs' + import type { PlaywrightTestConfig } from '@playwright/test' import { devices } from '@playwright/test' @@ -18,6 +20,7 @@ export const basePlaywrightConfig: PlaywrightTestConfig = { { name: 'chromium', use: { ...devices['Desktop Chrome'] }, + dependencies: fs.existsSync('./tests/setup.ts') ? ['setup'] : undefined, }, // { diff --git a/tasks/smoke-tests/rsc/playwright.config.ts b/tasks/smoke-tests/rsc/playwright.config.ts new file mode 100644 index 000000000000..f3323ded09d4 --- /dev/null +++ b/tasks/smoke-tests/rsc/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_TEST_PROJECT_PATH, + url: 'http://localhost:8910', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, +}) diff --git a/tasks/smoke-tests/rsc/tests/rsc.spec.ts b/tasks/smoke-tests/rsc/tests/rsc.spec.ts new file mode 100644 index 000000000000..5866a974276a --- /dev/null +++ b/tasks/smoke-tests/rsc/tests/rsc.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test' + +test('Setting up RSC should give you a test project with a client side counter component', async ({ + page, +}) => { + await page.goto('/') + + const h3 = await page.locator('h3').first().innerHTML() + expect(h3).toMatch(/This is a server component/) + await page.locator('p').filter({ hasText: 'Count: 0' }).first().isVisible() + + await page.locator('button').filter({ hasText: 'Increment' }).click() + + const count = await page.locator('p').first().innerText() + expect(count).toMatch('Count: 1') + + page.close() +})