diff --git a/.github/actions/cli-scaffold-e2e/action.yml b/.github/actions/cli-scaffold-e2e/action.yml new file mode 100644 index 000000000..91304dbea --- /dev/null +++ b/.github/actions/cli-scaffold-e2e/action.yml @@ -0,0 +1,151 @@ +name: CLI scaffold end to end +description: Scaffold a template with the packed CLI, then install and build the generated app. + +inputs: + template: + description: Template key passed to `openui create --template`. + required: true + backend-framework: + description: Overlay key passed to `openui create --backend-framework`. + required: false + default: default + node-version: + description: Node.js major version used to run the CLI and the generated app. + required: true + package-manager: + description: Package manager the CLI should select (npm or pnpm). + required: true + package-manager-version: + description: Expected package manager major version. + required: true + +runs: + using: composite + steps: + - uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version }} + + - name: Set up npm ${{ inputs.package-manager-version }} + if: inputs.package-manager == 'npm' + shell: bash + run: npm install --global npm@${{ inputs.package-manager-version }} + + - uses: pnpm/action-setup@v6 + if: inputs.package-manager == 'pnpm' + with: + version: ${{ inputs.package-manager-version }} + # The matrix version must win here; reading the repo root's + # packageManager field (pnpm@10.33.0) alongside an explicit version + # is a hard error in action-setup v6, so point it at a manifest + # without that field. + package_json_file: packages/openui-cli/package.json + + - name: Verify package manager version + shell: bash + # Outside the repo checkout: inside it, pnpm >=10 self-switches to the + # root packageManager version (pnpm@10.33.0) and would report that + # instead of the matrix-installed version. The e2e steps below all run + # under runner.temp too, so this verifies what they will actually use. + working-directory: ${{ runner.temp }} + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + EXPECTED_MAJOR: ${{ inputs.package-manager-version }} + run: | + actual_version="$("${PACKAGE_MANAGER}" --version)" + if [[ "${actual_version}" != "${EXPECTED_MAJOR}".* ]]; then + echo "Expected ${PACKAGE_MANAGER} ${EXPECTED_MAJOR}.x, got ${actual_version}" >&2 + exit 1 + fi + + - uses: actions/download-artifact@v8 + with: + name: openui-cli-package + path: ${{ runner.temp }}/cli-package + + - name: Locate CLI package and prepare workspace + id: cli-package + shell: bash + env: + CLI_PACKAGE_DIR: ${{ runner.temp }}/cli-package + CLI_E2E_DIR: ${{ runner.temp }}/cli-e2e + run: | + node --input-type=module -e " + import fs from 'node:fs'; + import path from 'node:path'; + const tarballs = fs.readdirSync(process.env.CLI_PACKAGE_DIR).filter((file) => file.endsWith('.tgz')); + if (tarballs.length !== 1) throw new Error('Expected exactly one CLI tarball'); + const tarball = path.join(process.env.CLI_PACKAGE_DIR, tarballs[0]); + fs.mkdirSync(process.env.CLI_E2E_DIR, { recursive: true }); + fs.appendFileSync(process.env.GITHUB_OUTPUT, 'path=' + tarball + '\n'); + " + + # OPENUI_DEBUG + OPENUI_SOURCE_DIR makes the CLI scaffold the checkout's + # templates instead of fetching them from `main`, so template changes are + # tested here. + - name: Run CLI with npm + if: inputs.package-manager == 'npm' + shell: bash + working-directory: ${{ runner.temp }}/cli-e2e + env: + OPENUI_DEBUG: "1" + OPENUI_SOURCE_DIR: ${{ github.workspace }} + run: >- + npm exec --yes --package="${{ steps.cli-package.outputs.path }}" -- + openui --no-telemetry create + --name generated-app + --template ${{ inputs.template }} + --backend-framework ${{ inputs.backend-framework }} + --api-key sk-test + --no-interactive + --no-skill + + - name: Run CLI with pnpm + if: inputs.package-manager == 'pnpm' + shell: bash + working-directory: ${{ runner.temp }}/cli-e2e + env: + OPENUI_DEBUG: "1" + OPENUI_SOURCE_DIR: ${{ github.workspace }} + run: >- + pnpm dlx "${{ steps.cli-package.outputs.path }}" + --no-telemetry create + --name generated-app + --template ${{ inputs.template }} + --backend-framework ${{ inputs.backend-framework }} + --api-key sk-test + --no-interactive + --no-skill + + # npm- and pnpm-installed node_modules can both satisfy either run command. + # Check manager metadata and lockfile retention to verify CLI selection. + - name: Verify selected package manager + shell: bash + working-directory: ${{ runner.temp }}/cli-e2e/generated-app + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + run: | + node --input-type=module -e " + import fs from 'node:fs'; + import path from 'node:path'; + const marker = process.env.PACKAGE_MANAGER === 'pnpm' + ? path.join('node_modules', '.modules.yaml') + : path.join('node_modules', '.package-lock.json'); + if (!fs.existsSync(marker)) throw new Error('Missing ' + process.env.PACKAGE_MANAGER + ' install marker: ' + marker); + const hasNpmLockfile = fs.existsSync('package-lock.json'); + if (process.env.PACKAGE_MANAGER === 'npm' && !hasNpmLockfile) { + throw new Error('npm-generated project is missing package-lock.json'); + } + if (process.env.PACKAGE_MANAGER !== 'npm' && hasNpmLockfile) { + throw new Error(process.env.PACKAGE_MANAGER + '-generated project retained package-lock.json'); + } + " + + - name: Build generated app + shell: bash + working-directory: ${{ runner.temp }}/cli-e2e/generated-app + run: ${{ inputs.package-manager }} run build + env: + OPENAI_API_KEY: sk-test + THESYS_API_KEY: sk-test + DEMO_USER_ID: test-user diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index 87383e96e..13792e39f 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -4,6 +4,7 @@ on: push: branches: [main] paths: + - ".github/actions/cli-scaffold-e2e/action.yml" - ".github/workflows/cli-e2e.yml" - "packages/openui-cli/**" - "templates/**" @@ -11,6 +12,7 @@ on: pull_request: branches: [main] paths: + - ".github/actions/cli-scaffold-e2e/action.yml" - ".github/workflows/cli-e2e.yml" - "packages/openui-cli/**" - "templates/**" @@ -148,115 +150,42 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: ./.github/actions/cli-scaffold-e2e with: + template: ${{ matrix.template }} + backend-framework: default node-version: ${{ matrix.node-version }} + package-manager: ${{ matrix.package-manager }} + package-manager-version: ${{ matrix.package-manager-version }} + + # The default overlay is covered above across operating systems and package + # manager majors; overlays only change dependencies and app code, so one + # current Linux configuration per package manager is enough. + cli-e2e-overlays: + name: ${{ matrix.template }} + ${{ matrix.overlay }} (${{ matrix.package-manager }}@11, ubuntu, Node 24) + needs: package-cli + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + template: + - openui-cloud + - openui-self-hosted + overlay: + - langgraph + - vercel-ai-sdk + - vercel-eve + package-manager: + - npm + - pnpm - - name: Set up npm ${{ matrix.package-manager-version }} - if: matrix.package-manager == 'npm' - run: npm install --global npm@${{ matrix.package-manager-version }} - - - uses: pnpm/action-setup@v6 - if: matrix.package-manager == 'pnpm' - with: - version: ${{ matrix.package-manager-version }} - # The matrix version must win here; reading the repo root's - # packageManager field (pnpm@10.33.0) alongside an explicit version - # is a hard error in action-setup v6, so point it at a manifest - # without that field. - package_json_file: packages/openui-cli/package.json - - - name: Verify package manager version - shell: bash - # Outside the repo checkout: inside it, pnpm >=10 self-switches to the - # root packageManager version (pnpm@10.33.0) and would report that - # instead of the matrix-installed version. The e2e steps below all run - # under runner.temp too, so this verifies what they will actually use. - working-directory: ${{ runner.temp }} - env: - PACKAGE_MANAGER: ${{ matrix.package-manager }} - EXPECTED_MAJOR: ${{ matrix.package-manager-version }} - run: | - actual_version="$("${PACKAGE_MANAGER}" --version)" - if [[ "${actual_version}" != "${EXPECTED_MAJOR}".* ]]; then - echo "Expected ${PACKAGE_MANAGER} ${EXPECTED_MAJOR}.x, got ${actual_version}" >&2 - exit 1 - fi + steps: + - uses: actions/checkout@v6 - - uses: actions/download-artifact@v8 + - uses: ./.github/actions/cli-scaffold-e2e with: - name: openui-cli-package - path: ${{ runner.temp }}/cli-package - - - name: Locate CLI package and prepare workspace - id: cli-package - shell: bash - env: - CLI_PACKAGE_DIR: ${{ runner.temp }}/cli-package - CLI_E2E_DIR: ${{ runner.temp }}/cli-e2e - run: | - node --input-type=module -e " - import fs from 'node:fs'; - import path from 'node:path'; - const tarballs = fs.readdirSync(process.env.CLI_PACKAGE_DIR).filter((file) => file.endsWith('.tgz')); - if (tarballs.length !== 1) throw new Error('Expected exactly one CLI tarball'); - const tarball = path.join(process.env.CLI_PACKAGE_DIR, tarballs[0]); - fs.mkdirSync(process.env.CLI_E2E_DIR, { recursive: true }); - fs.appendFileSync(process.env.GITHUB_OUTPUT, 'path=' + tarball + '\n'); - " - - - name: Run CLI with npm - if: matrix.package-manager == 'npm' - working-directory: ${{ runner.temp }}/cli-e2e - run: >- - npm exec --yes --package="${{ steps.cli-package.outputs.path }}" -- - openui --no-telemetry create - --name generated-app - --template ${{ matrix.template }} - --api-key sk-test - --no-interactive - --no-skill - - - name: Run CLI with pnpm - if: matrix.package-manager == 'pnpm' - working-directory: ${{ runner.temp }}/cli-e2e - run: >- - pnpm dlx "${{ steps.cli-package.outputs.path }}" - --no-telemetry create - --name generated-app - --template ${{ matrix.template }} - --api-key sk-test - --no-interactive - --no-skill - - # npm- and pnpm-installed node_modules can both satisfy either run command. - # Check manager metadata and lockfile retention to verify CLI selection. - - name: Verify selected package manager - working-directory: ${{ runner.temp }}/cli-e2e/generated-app - shell: bash - env: - PACKAGE_MANAGER: ${{ matrix.package-manager }} - run: | - node --input-type=module -e " - import fs from 'node:fs'; - import path from 'node:path'; - const marker = process.env.PACKAGE_MANAGER === 'pnpm' - ? path.join('node_modules', '.modules.yaml') - : path.join('node_modules', '.package-lock.json'); - if (!fs.existsSync(marker)) throw new Error('Missing ' + process.env.PACKAGE_MANAGER + ' install marker: ' + marker); - const hasNpmLockfile = fs.existsSync('package-lock.json'); - if (process.env.PACKAGE_MANAGER === 'npm' && !hasNpmLockfile) { - throw new Error('npm-generated project is missing package-lock.json'); - } - if (process.env.PACKAGE_MANAGER !== 'npm' && hasNpmLockfile) { - throw new Error(process.env.PACKAGE_MANAGER + '-generated project retained package-lock.json'); - } - " - - - name: Build generated app - working-directory: ${{ runner.temp }}/cli-e2e/generated-app - run: ${{ matrix.package-manager }} run build - env: - OPENAI_API_KEY: sk-test - THESYS_API_KEY: sk-test - DEMO_USER_ID: test-user + template: ${{ matrix.template }} + backend-framework: ${{ matrix.overlay }} + node-version: 24 + package-manager: ${{ matrix.package-manager }} + package-manager-version: "11" diff --git a/packages/openui-cli/src/lib/checkout.ts b/packages/openui-cli/src/lib/checkout.ts index e94cfe27e..a48c239a3 100644 --- a/packages/openui-cli/src/lib/checkout.ts +++ b/packages/openui-cli/src/lib/checkout.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { isTruthyEnv } from "./env"; import { CreateError } from "./telemetry"; const GIT_TIMEOUT_MS = 60_000; @@ -13,6 +14,30 @@ const SOURCE_REPO = "openui"; const SOURCE_REF = "main"; const SOURCE_GIT_URL = `https://github.com/${SOURCE_OWNER}/${SOURCE_REPO}.git`; +/** + * Absolute path to a source-repository checkout to read templates from + * instead of fetching `main` from GitHub, so CI can scaffold the templates of + * the commit under test. Undocumented: requires `OPENUI_DEBUG`. + */ +function localSourceDir(): string | undefined { + if (!isTruthyEnv(process.env["OPENUI_DEBUG"])) return undefined; + const dir = process.env["OPENUI_SOURCE_DIR"]?.trim(); + return dir || undefined; +} + +function localSourcePath(sourceDir: string, normalizedPath: string): string { + const resolved = path.join(sourceDir, ...normalizedPath.split("/")); + if (!fs.existsSync(resolved)) { + throw new CreateError( + "source_checkout", + `Path "${normalizedPath}" was not in OPENUI_SOURCE_DIR (${sourceDir}).`, + "filesystem", + "SOURCE_MISSING", + ); + } + return resolved; +} + export type SourceFetchOptions = { dest?: string; }; @@ -98,6 +123,10 @@ function runGit( export async function fetchSourceFile(repoPath: string): Promise { const normalizedPath = posixRepoPath(repoPath); + const sourceDir = localSourceDir(); + if (sourceDir) { + return { content: fs.readFileSync(localSourcePath(sourceDir, normalizedPath), "utf8") }; + } const url = `https://raw.githubusercontent.com/${SOURCE_OWNER}/${SOURCE_REPO}/${SOURCE_REF}/${normalizedPath}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); @@ -134,6 +163,12 @@ export async function checkoutSource( opts: SourceFetchOptions = {}, ): Promise { const normalizedPath = posixRepoPath(repoPath); + const sourceDir = localSourceDir(); + if (sourceDir) { + const dest = opts.dest ?? fs.mkdtempSync(path.join(os.tmpdir(), "openui-src-")); + copyDir(localSourcePath(sourceDir, normalizedPath), dest); + return { dir: dest }; + } const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openui-src-")); try { await runGit(["init", "--quiet"], { cwd: tmpDir }); diff --git a/templates/openui-cloud/overlays/vercel-eve/src/eve-chat.ts b/templates/openui-cloud/overlays/vercel-eve/src/eve-chat.ts index 699dbd82a..819147f6c 100644 --- a/templates/openui-cloud/overlays/vercel-eve/src/eve-chat.ts +++ b/templates/openui-cloud/overlays/vercel-eve/src/eve-chat.ts @@ -1,5 +1,4 @@ import { eveAdapter, type ChatLLM, type Message } from "@openuidev/react-ui"; -import type { SessionState } from "eve/client"; // Eve's native HTTP session protocol (same-origin, proxied by `withEve`): // POST /eve/v1/session -> create a session @@ -10,6 +9,13 @@ import type { SessionState } from "eve/client"; const EVE_PREFIX = "/eve/v1"; const SESSION_ID_HEADER = "x-eve-session-id"; +/** Per-thread cursor. Kept local — Eve 0.18+ renamed/narrowed `SessionState`. */ +type EveSessionCursor = { + sessionId?: string; + streamIndex: number; + continuationToken?: string; +}; + interface KVStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; @@ -46,17 +52,17 @@ function getClientStorage(): KVStorage { }; } -function loadSession(storage: KVStorage, threadId: string): SessionState { +function loadSession(storage: KVStorage, threadId: string): EveSessionCursor { try { const raw = storage.getItem(sessionKey(threadId)); - if (raw) return JSON.parse(raw) as SessionState; + if (raw) return JSON.parse(raw) as EveSessionCursor; } catch { // fall through to a fresh cursor } return { streamIndex: 0 }; } -function saveSession(storage: KVStorage, threadId: string, state: SessionState): void { +function saveSession(storage: KVStorage, threadId: string, state: EveSessionCursor): void { storage.setItem(sessionKey(threadId), JSON.stringify(state)); } @@ -70,7 +76,7 @@ function saveSession(storage: KVStorage, threadId: string, state: SessionState): export function createEveLLM(storage: KVStorage = getClientStorage()): ChatLLM { // Cursor for the run in flight. OpenUI finishes consuming one send() stream // before starting the next, so a single slot is enough. - let active: { threadId: string; state: SessionState } | null = null; + let active: { threadId: string; state: EveSessionCursor } | null = null; const send: ChatLLM["send"] = async ({ messages, threadId, signal }): Promise => { const state = loadSession(storage, threadId); diff --git a/templates/openui-cloud/overlays/vercel-eve/tsconfig.json b/templates/openui-cloud/overlays/vercel-eve/tsconfig.json index 50d96675b..c83dd39cc 100644 --- a/templates/openui-cloud/overlays/vercel-eve/tsconfig.json +++ b/templates/openui-cloud/overlays/vercel-eve/tsconfig.json @@ -7,6 +7,7 @@ "strict": true, "noEmit": true, "esModuleInterop": true, + "allowImportingTsExtensions": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, @@ -31,5 +32,5 @@ ".eve/**/*.d.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "agent"] }