diff --git a/.github/workflows/qa-flow.yml b/.github/workflows/qa-flow.yml new file mode 100644 index 00000000000..43eafccea8a --- /dev/null +++ b/.github/workflows/qa-flow.yml @@ -0,0 +1,92 @@ +name: QA flow + +# Automated CLI Pre-release QA flow (packages/qa): runs the QA-doc steps that +# need no human interaction, non-blocking, and publishes a summary that mirrors +# the doc structure (with the remaining manual checklist for gardeners). +# +# - CI runs (PRs / merge queue): tests the CLI built from the current branch. +# - Manual runs (workflow_dispatch): tests a published version, default nightly. + +on: + pull_request: + merge_group: + workflow_dispatch: + inputs: + cli-version: + type: string + required: true + default: 'nightly' + description: 'npm dist-tag or version of @shopify/cli to QA (or "repo" for the branch build)' + +concurrency: + group: qa-flow-${{ github.event_name }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + DEBUG: '1' + PNPM_VERSION: '10.11.1' + QA_NODE_VERSION: '24.1.0' + +jobs: + qa-flow: + name: 'QA flow (${{ matrix.os }})' + # Non-blocking: failures show up in the summary, not as a red required check. + continue-on-error: true + # Secrets are unavailable to forks; run only for internal PRs, the merge + # queue and manual dispatches. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + # Rollout starts with macOS (the QA doc's default OS); + # ubuntu-latest and windows-latest follow once stable. + os: ['macos-latest'] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Setup deps + uses: ./.github/actions/setup-cli-deps + with: + node-version: ${{ env.QA_NODE_VERSION }} + + - name: Build CLI from branch + if: github.event_name != 'workflow_dispatch' || inputs.cli-version == 'repo' + run: pnpm nx run-many --all --skip-nx-cache --target=build --output-style=stream + + - name: Install @shopify/cli@${{ inputs.cli-version }} + if: github.event_name == 'workflow_dispatch' && inputs.cli-version != 'repo' + id: install-cli + run: | + mkdir -p "$RUNNER_TEMP/qa-cli" + npm install --prefix "$RUNNER_TEMP/qa-cli" "@shopify/cli@${{ inputs.cli-version }}" --registry=https://registry.npmjs.org + BIN="$RUNNER_TEMP/qa-cli/node_modules/@shopify/cli/bin/run.js" + VERSION=$(node -p "require('$RUNNER_TEMP/qa-cli/node_modules/@shopify/cli/package.json').version") + echo "bin=$BIN" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Installed @shopify/cli@$VERSION" + + - name: Rebuild node-pty + run: pnpm rebuild node-pty + + - name: Run QA flow + working-directory: packages/qa + env: + QA_CLI_BIN: ${{ steps.install-cli.outputs.bin }} + QA_EXPECTED_VERSION: ${{ steps.install-cli.outputs.version }} + QA_ISOLATE: '1' + QA_ORG_ID: ${{ secrets.QA_ORG_ID }} + QA_REPORT_DIR: ${{ github.workspace }}/qa-report + SHOPIFY_APP_AUTOMATION_TOKEN: ${{ secrets.QA_APP_AUTOMATION_TOKEN }} + run: pnpm qa + + - name: Upload QA report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: qa-report-${{ matrix.os }} + path: qa-report/ + retention-days: 30 diff --git a/packages/qa/.gitignore b/packages/qa/.gitignore new file mode 100644 index 00000000000..5dda80d9c8e --- /dev/null +++ b/packages/qa/.gitignore @@ -0,0 +1,2 @@ +qa-report/ +node_modules/ diff --git a/packages/qa/README.md b/packages/qa/README.md new file mode 100644 index 00000000000..a6894a9be1f --- /dev/null +++ b/packages/qa/README.md @@ -0,0 +1,67 @@ +# @shopify/qa — CLI Pre-release QA flow runner + +Automates the steps of the [CLI Pre-release QA flow doc](https://docs.google.com/document/d/1XX6QnS6kKZTT1shcCZVcso74VWn-Ui4V2IASenHHi1E/edit) +that need **no human interaction**, by driving the CLI directly (no Playwright, +no test framework): non-interactive commands run via `execa`, interactive ones +(`app dev`, `app config link`, `hydrogen init`) run in a pseudo-terminal via +`node-pty` and are driven with the same keypresses the doc describes (`g`, `q`, +Enter, CTRL+C). + +Every checklist item of the doc is represented, in order: + +- **auto** — executed by the runner. +- **manual** — needs a human. This covers browser/visual checks and the whole + `shopify app dev` block (kept manual by team decision — it is an interactive + session). Reported as `⏭️ manual` and listed in the "Remaining manual + checklist" section of the summary — never silently dropped. +- **delegated** — the Theme section, owned by the themes team per the doc. + +The summary (`qa-summary.md`, also appended to `$GITHUB_STEP_SUMMARY` on CI) +mirrors the QA doc structure section by section. + +## Running locally + +```sh +# Build the CLI first (the runner targets the repo build by default) +pnpm nx run-many --all --target=build + +cd packages/qa +pnpm install + +# Requires an authenticated CLI session (shopify auth login) or +# SHOPIFY_APP_AUTOMATION_TOKEN, plus an org: +QA_ORG_ID= pnpm qa + +# Run a subset while iterating: +QA_ONLY=hydrogen pnpm qa +QA_ONLY=prep,apps QA_ORG_ID=… pnpm qa +``` + +## Environment variables + +| Variable | Purpose | +| --- | --- | +| `QA_CLI_BIN` | Path to the CLI under test. Default: `packages/cli/bin/run.js` (repo build). Point it at an installed `shopify` binary to QA a published version. | +| `QA_EXPECTED_VERSION` | Assert `shopify version` equals this (doc: "the version should match the nightly you just created"). | +| `QA_ORG_ID` / `E2E_ORG_ID` | Organization used by `app init`. | +| `QA_PACKAGE_MANAGER` | Package manager for `app init` (default `pnpm`). | +| `QA_ISOLATE` | `1` = fresh XDG dirs (don't reuse the local CLI session). Set on CI. | +| `QA_WORK_DIR` | Scratch dir (default: fresh temp dir). | +| `QA_ONLY` | Comma-separated section filter (`prep`, `general`, `apps`, `theme`, `hydrogen`). | +| `QA_REPORT_DIR` | Where `qa-summary.md` / `qa-report.json` are written (default `./qa-report`). | +| `SHOPIFY_APP_AUTOMATION_TOKEN` | Headless auth for app-platform commands on CI. | + +## Auth + +- **Locally**: the runner reuses your ambient `shopify auth login` session. +- **CI**: `QA_ISOLATE=1` plus `SHOPIFY_APP_AUTOMATION_TOKEN` (Genghis e2e org). + Steps that turn out to require a browser user session are expected to fail + loudly in the report rather than hang: every interactive prompt has a + timeout that dumps the captured output. + +## Relationship to packages/e2e + +`packages/e2e` is the Playwright-based PR test suite. This package is +intentionally independent (per-team decision): it duplicates the few markers it +needs (e.g. the dev "Ready, watching for changes" string) instead of importing +from e2e, so the QA flow stays a faithful, standalone replica of the doc. diff --git a/packages/qa/package.json b/packages/qa/package.json new file mode 100644 index 00000000000..2c73f4fb8fd --- /dev/null +++ b/packages/qa/package.json @@ -0,0 +1,22 @@ +{ + "name": "@shopify/qa", + "version": "1.0.0", + "packageManager": "pnpm@10.11.1", + "private": true, + "type": "module", + "scripts": { + "qa": "tsx src/run.ts", + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "18.19.130", + "execa": "^7.2.0", + "node-pty": "^1.0.0", + "strip-ansi": "^7.2.0", + "tsx": "^4.23.1", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/packages/qa/src/context.ts b/packages/qa/src/context.ts new file mode 100644 index 00000000000..2d25f3c84a1 --- /dev/null +++ b/packages/qa/src/context.ts @@ -0,0 +1,114 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import {fileURLToPath} from 'url' +import type {PtyProcess} from './proc.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +export interface CtxState { + /** Directory of the app created by `app init`. */ + appDir?: string + /** Name given to the app created by `app init`. */ + appName?: string + /** Directory of the hydrogen app. */ + hydrogenDir?: string + /** Name of the secondary config created by `app config link` (e.g. "staging"). */ + secondaryConfig?: string + /** Any pty processes to kill during teardown. */ + ptyProcs: PtyProcess[] +} + +export interface Ctx { + /** argv prefix used to invoke the CLI under test, e.g. ['node', '/path/to/run.js'] or ['shopify']. */ + cliInvoke: string[] + /** Human description of what is being tested (repo build vs installed package). */ + cliTarget: string + /** Root scratch directory for this run. */ + workDir: string + /** Environment passed to every CLI invocation. */ + env: {[key: string]: string} + orgId?: string + /** When set, the version step asserts `shopify version` equals this. */ + expectedVersion?: string + state: CtxState + log: (msg: string) => void +} + +function repoRoot(): string { + // packages/qa/src -> repo root + return path.resolve(__dirname, '..', '..', '..') +} + +/** + * Build the run context from environment variables. + * + * QA_CLI_BIN Path to the CLI entrypoint (JS file or executable). + * Defaults to the repo build at packages/cli/bin/run.js. + * QA_EXPECTED_VERSION Assert `shopify version` equals this value. + * QA_ORG_ID Organization id used for `app init` (falls back to E2E_ORG_ID). + * QA_WORK_DIR Scratch dir (defaults to a fresh mkdtemp). + * QA_ISOLATE When "1", run with fresh XDG dirs so the ambient CLI session + * is not used. CI sets this; local runs default to ambient auth. + */ +export function createContext(log: (msg: string) => void = defaultLog): Ctx { + const binEnv = process.env.QA_CLI_BIN + const defaultBin = path.join(repoRoot(), 'packages', 'cli', 'bin', 'run.js') + const bin = binEnv ?? defaultBin + + let cliInvoke: string[] + if (bin.endsWith('.js') || bin.endsWith('.mjs') || bin.endsWith('.cjs')) { + cliInvoke = [process.execPath, bin] + } else { + cliInvoke = [bin] + } + const cliTarget = binEnv ? `installed CLI at ${bin}` : 'repo build (packages/cli/bin/run.js)' + + const workDir = + process.env.QA_WORK_DIR ?? fs.mkdtempSync(path.join(os.tmpdir(), 'shopify-qa-')) + fs.mkdirSync(workDir, {recursive: true}) + + const env: {[key: string]: string} = {} + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) env[key] = value + } + // Deterministic, machine-readable CLI output. + env.FORCE_COLOR = '0' + env.SHOPIFY_CLI_NO_ANALYTICS = '1' + env.SHOPIFY_FLAG_VERBOSE = '' + delete env.NODE_OPTIONS + // pnpm sets INIT_CWD to where `pnpm qa` was invoked; cli-kit's cwd() prefers + // it over the real process cwd, which would break every --path resolution. + // proc.ts re-points INIT_CWD/PWD at the effective cwd per invocation. + delete env.INIT_CWD + delete env.PWD + // Some environments (nix shells, agent harnesses) export NPM_CONFIG_NODE_VERSION; + // pnpm trusts it over the real node version and fails template engine checks. + for (const key of Object.keys(env)) { + if (key.toLowerCase() === 'npm_config_node_version') delete env[key] + } + + if (process.env.QA_ISOLATE === '1') { + const authDir = path.join(workDir, 'xdg') + for (const name of ['XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME']) { + const dir = path.join(authDir, name) + fs.mkdirSync(dir, {recursive: true}) + env[name] = dir + } + } + + return { + cliInvoke, + cliTarget, + workDir, + env, + orgId: process.env.QA_ORG_ID ?? process.env.E2E_ORG_ID, + expectedVersion: process.env.QA_EXPECTED_VERSION, + state: {ptyProcs: []}, + log, + } +} + +function defaultLog(msg: string): void { + process.stdout.write(`[qa] ${msg}\n`) +} diff --git a/packages/qa/src/proc.ts b/packages/qa/src/proc.ts new file mode 100644 index 00000000000..67f449b7209 --- /dev/null +++ b/packages/qa/src/proc.ts @@ -0,0 +1,287 @@ +/** + * Process helpers for driving the CLI under test. + * + * `exec` runs non-interactive commands via execa. + * `spawnPty` runs interactive commands (app dev, config link, hydrogen init) + * in a pseudo-terminal so the CLI renders its interactive UI and accepts + * keypresses — no browser or test framework involved. + */ +import * as fs from 'fs' +import * as path from 'path' +import {createRequire} from 'module' +import stripAnsi from 'strip-ansi' +import {execa} from 'execa' +import * as pty from 'node-pty' +import type {Ctx} from './context.js' + +/** + * pnpm extracts node-pty prebuilds without the executable bit on macOS/Linux, + * which makes every pty.spawn fail with "posix_spawnp failed.". Fix it once. + */ +export function ensurePtySpawnHelperExecutable(): void { + if (process.platform === 'win32') return + try { + const require = createRequire(import.meta.url) + const packageDir = path.dirname(require.resolve('node-pty/package.json')) + const prebuilds = path.join(packageDir, 'prebuilds') + if (!fs.existsSync(prebuilds)) return + for (const platformDir of fs.readdirSync(prebuilds)) { + const helper = path.join(prebuilds, platformDir, 'spawn-helper') + if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755) + } + } catch { + // Best effort — a real failure will surface as a spawn error with context. + } +} + +export interface ExecResult { + stdout: string + stderr: string + exitCode: number +} + +export interface ExecOptions { + cwd?: string + env?: {[key: string]: string} + timeoutMs?: number + /** Piped to stdin (used by `app function run`). */ + input?: string +} + +const DEFAULT_EXEC_TIMEOUT = 5 * 60_000 + +export async function exec(ctx: Ctx, args: string[], opts: ExecOptions = {}): Promise { + const [command, ...prefix] = ctx.cliInvoke + ctx.log(`$ shopify ${args.join(' ')}`) + const cwd = opts.cwd ?? ctx.workDir + // CI=1 makes the CLI fail fast on unexpected prompts instead of hanging. + const result = await execa(command as string, [...prefix, ...args], { + cwd, + env: {...ctx.env, ...opts.env, INIT_CWD: cwd, PWD: cwd, CI: '1'}, + extendEnv: false, + timeout: opts.timeoutMs ?? DEFAULT_EXEC_TIMEOUT, + input: opts.input, + reject: false, + all: true, + }) + return { + stdout: stripAnsi(result.stdout ?? ''), + stderr: stripAnsi(result.stderr ?? ''), + exitCode: result.exitCode ?? 1, + } +} + +/** Throw with a readable error when a command exits non-zero. */ +export function expectSuccess(result: ExecResult, what: string): ExecResult { + if (result.exitCode !== 0) { + throw new Error(`${what} exited with ${result.exitCode}\n--- output ---\n${tail(result.stdout + result.stderr)}`) + } + return result +} + +export function tail(text: string, lines = 40): string { + const all = text.split('\n') + return all.slice(Math.max(0, all.length - lines)).join('\n') +} + +export interface WaitOptions { + timeoutMs?: number +} + +export function settle(ms = 250): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** Allocate a free localhost port. */ +export async function freePort(): Promise { + const net = await import('net') + return new Promise((resolve, reject) => { + const server = net.createServer() + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (address && typeof address === 'object') { + const port = address.port + server.close(() => resolve(port)) + } else { + server.close(() => reject(new Error('Could not allocate a free port'))) + } + }) + server.on('error', reject) + }) +} + +export interface PtyProcess { + /** Wait until the (ANSI-stripped) output matches. */ + waitFor(match: string | RegExp, opts?: WaitOptions): Promise + /** Send raw input, e.g. 'q', '\r', '\x03' (Ctrl+C). */ + write(data: string): void + /** Send a line followed by Enter. */ + sendLine(line: string): void + waitForExit(timeoutMs?: number): Promise + kill(): void + /** All output captured so far, ANSI-stripped. */ + output(): string + exited: boolean +} + +const DEFAULT_WAIT_TIMEOUT = 3 * 60_000 + +export function spawnPty( + ctx: Ctx, + args: string[], + opts: {cwd?: string; env?: {[key: string]: string}} = {}, +): PtyProcess { + const [command, ...prefix] = ctx.cliInvoke + ctx.log(`$ shopify ${args.join(' ')} (pty)`) + + const cwd = opts.cwd ?? ctx.workDir + // Interactive prompts only render when the CLI does not think it's on CI. + const env: {[key: string]: string} = {...ctx.env, ...opts.env, INIT_CWD: cwd, PWD: cwd} + delete env.CI + const proc = pty.spawn(command as string, [...prefix, ...args], { + name: 'xterm-color', + cols: 120, + rows: 40, + cwd, + env, + }) + + let buffer = '' + let exited = false + let exitCode: number | undefined + const exitWaiters: ((code: number) => void)[] = [] + interface Waiter { + match: string | RegExp + resolve: () => void + from: number + } + const waiters: Waiter[] = [] + + const matches = (match: string | RegExp, from: number): boolean => { + const haystack = buffer.slice(from) + return typeof match === 'string' ? haystack.includes(match) : match.test(haystack) + } + + proc.onData((data) => { + buffer += stripAnsi(data) + for (let i = waiters.length - 1; i >= 0; i--) { + const waiter = waiters[i] as Waiter + if (matches(waiter.match, waiter.from)) { + waiters.splice(i, 1) + waiter.resolve() + } + } + }) + + proc.onExit(({exitCode: code}) => { + exited = true + exitCode = code + while (exitWaiters.length) exitWaiters.shift()?.(code) + }) + + const handle: PtyProcess = { + get exited() { + return exited + }, + output: () => buffer, + write: (data) => proc.write(data), + sendLine: (line) => proc.write(`${line}\r`), + kill: () => { + if (exited) return + // Kill the whole process group: dev servers spawn children (workerd, + // vite) that would otherwise survive and keep ports bound. + if (process.platform !== 'win32') { + try { + process.kill(-proc.pid, 'SIGTERM') + return + } catch { + // Fall through to killing the direct child only. + } + } + proc.kill() + }, + waitFor: (match, waitOpts = {}) => { + const from = 0 + if (matches(match, from)) return Promise.resolve() + if (exited) { + return Promise.reject( + new Error(`Process exited (code ${exitCode}) before matching ${String(match)}\n--- output ---\n${tail(buffer)}`), + ) + } + return new Promise((resolve, reject) => { + const timeoutMs = waitOpts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT + const timer = setTimeout(() => { + const index = waiters.findIndex((waiter) => waiter.resolve === wrapped) + if (index >= 0) waiters.splice(index, 1) + reject(new Error(`Timed out (${timeoutMs}ms) waiting for ${String(match)}\n--- output ---\n${tail(buffer)}`)) + }, timeoutMs) + const wrapped = () => { + clearTimeout(timer) + resolve() + } + waiters.push({match, resolve: wrapped, from}) + exitWaiters.push(() => { + const index = waiters.findIndex((waiter) => waiter.resolve === wrapped) + if (index >= 0) { + waiters.splice(index, 1) + clearTimeout(timer) + reject( + new Error( + `Process exited (code ${exitCode}) before matching ${String(match)}\n--- output ---\n${tail(buffer)}`, + ), + ) + } + }) + }) + }, + waitForExit: (timeoutMs = DEFAULT_WAIT_TIMEOUT) => { + if (exited) return Promise.resolve(exitCode ?? 0) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out (${timeoutMs}ms) waiting for exit`)), timeoutMs) + exitWaiters.push((code) => { + clearTimeout(timer) + resolve(code) + }) + }) + }, + } + + ctx.state.ptyProcs.push(handle) + return handle +} + +/** Simple HTTP fetch helper with timeout; avoids extra deps (Node >=22 has fetch). */ +export async function httpRequest( + url: string, + init: {method?: string; headers?: {[key: string]: string}; body?: string; timeoutMs?: number} = {}, +): Promise<{status: number; body: string}> { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), init.timeoutMs ?? 15_000) + try { + const response = await fetch(url, { + method: init.method ?? 'GET', + headers: init.headers, + body: init.body, + signal: controller.signal, + }) + const body = await response.text() + return {status: response.status, body} + } finally { + clearTimeout(timer) + } +} + +export async function retry(fn: () => Promise, attempts: number, delayMs: number): Promise { + let lastError: unknown + for (let attempt = 0; attempt < attempts; attempt++) { + try { + // eslint-disable-next-line no-await-in-loop + return await fn() + } catch (error) { + lastError = error + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + } + throw lastError +} diff --git a/packages/qa/src/report.ts b/packages/qa/src/report.ts new file mode 100644 index 00000000000..b78dcbbf517 --- /dev/null +++ b/packages/qa/src/report.ts @@ -0,0 +1,104 @@ +import * as fs from 'fs' +import type {QAReport, StepResult, StepStatus} from './types.js' + +const STATUS_ICON: {[key in StepStatus]: string} = { + pass: '✅', + fail: '❌', + skipped: '⏭️', + blocked: '🚫', +} + +function statusLabel(step: StepResult): string { + if (step.status === 'skipped' && step.kind === 'manual') return '⏭️ manual' + if (step.status === 'skipped' && step.kind === 'delegated') return '➡️ delegated' + return `${STATUS_ICON[step.status]} ${step.status}` +} + +function duration(ms: number): string { + if (ms === 0) return '' + if (ms < 1000) return `${ms}ms` + const seconds = Math.round(ms / 1000) + if (seconds < 60) return `${seconds}s` + return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s` +} + +export function renderMarkdown(report: QAReport): string { + const lines: string[] = [] + lines.push('# CLI Pre-release QA flow — automated run') + lines.push('') + lines.push(`- **CLI under test:** ${report.cliTarget}`) + lines.push(`- **Version:** ${report.cliVersion || 'unknown'}`) + lines.push(`- **OS:** ${report.os}`) + lines.push(`- **Started:** ${report.startedAt} — **finished:** ${report.finishedAt}`) + lines.push('') + + const all = report.sections.flatMap((section) => section.steps) + const counts = { + pass: all.filter((step) => step.status === 'pass').length, + fail: all.filter((step) => step.status === 'fail').length, + blocked: all.filter((step) => step.status === 'blocked').length, + manual: all.filter((step) => step.kind === 'manual').length, + delegated: all.filter((step) => step.kind === 'delegated').length, + } + lines.push( + `**${counts.pass} passed** · ${counts.fail} failed · ${counts.blocked} blocked · ` + + `${counts.manual} left to manual QA · ${counts.delegated} delegated`, + ) + lines.push('') + + for (const section of report.sections) { + lines.push(`## ${section.title}`) + lines.push('') + lines.push('| QA doc step | Result | Time | Notes |') + lines.push('|---|---|---|---|') + for (const step of section.steps) { + const note = step.status === 'fail' ? 'see error details below' : step.note ?? '' + lines.push( + `| ${escapeCell(step.doc)} | ${statusLabel(step)} | ${duration(step.durationMs)} | ${escapeCell(note)} |`, + ) + } + lines.push('') + const failures = section.steps.filter((step) => step.status === 'fail') + for (const failure of failures) { + lines.push(`
${failure.id} — error details`) + lines.push('') + lines.push('```') + lines.push(failure.error ?? 'no error captured') + lines.push('```') + lines.push('
') + lines.push('') + } + } + + const manualSteps = all.filter((step) => step.kind === 'manual') + if (manualSteps.length > 0) { + lines.push('## Remaining manual checklist') + lines.push('') + lines.push('These QA-doc items still need a human (browser/visual checks):') + lines.push('') + for (const step of manualSteps) { + lines.push(`- [ ] ${step.doc}${step.note ? ` _(${step.note})_` : ''}`) + } + lines.push('') + } + + return lines.join('\n') +} + +function escapeCell(text: string): string { + return text.replace(/\|/g, '\\|').replace(/\n/g, ' ') +} + +export function writeReports(report: QAReport, outDir: string): {markdownPath: string; jsonPath: string} { + fs.mkdirSync(outDir, {recursive: true}) + const markdown = renderMarkdown(report) + const markdownPath = `${outDir}/qa-summary.md` + const jsonPath = `${outDir}/qa-report.json` + fs.writeFileSync(markdownPath, markdown) + fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2)) + + const stepSummary = process.env.GITHUB_STEP_SUMMARY + if (stepSummary) fs.appendFileSync(stepSummary, `${markdown}\n`) + + return {markdownPath, jsonPath} +} diff --git a/packages/qa/src/run.ts b/packages/qa/src/run.ts new file mode 100644 index 00000000000..e04c6401994 --- /dev/null +++ b/packages/qa/src/run.ts @@ -0,0 +1,102 @@ +/** + * CLI Pre-release QA flow runner. + * + * Executes the QA doc steps that need no human interaction directly against + * the CLI under test (repo build by default, or QA_CLI_BIN), records one + * result per QA-doc checklist item and emits a summary that mirrors the doc. + * + * Usage: pnpm --filter @shopify/qa qa (all sections) + * QA_ONLY=hydrogen pnpm --filter @shopify/qa qa + */ +import * as path from 'path' +import {createContext} from './context.js' +import {ensurePtySpawnHelperExecutable, exec} from './proc.js' +import {extractVersion} from './steps/prep.js' +import {writeReports} from './report.js' +import {appsSection} from './steps/apps.js' +import {crossOSSection, hydrogenSection} from './steps/hydrogen.js' +import {generalSection, prepSection} from './steps/prep.js' +import {themeSection} from './steps/theme.js' +import type {QAReport, SectionDef, SectionResult, StepResult} from './types.js' + +const ALL_SECTIONS: SectionDef[] = [prepSection, generalSection, appsSection, themeSection, hydrogenSection, crossOSSection] + +function selectedSections(): SectionDef[] { + const only = process.env.QA_ONLY + if (!only) return ALL_SECTIONS + const wanted = only.split(',').map((part) => part.trim().toLowerCase()) + return ALL_SECTIONS.filter((section) => wanted.some((want) => section.title.toLowerCase().includes(want))) +} + +async function main(): Promise { + ensurePtySpawnHelperExecutable() + const ctx = createContext() + const startedAt = new Date().toISOString() + ctx.log(`CLI under test: ${ctx.cliTarget}`) + ctx.log(`work dir: ${ctx.workDir}`) + + const sections: SectionResult[] = [] + for (const section of selectedSections()) { + ctx.log(`── ${section.title} ──`) + const results: StepResult[] = [] + let chainBroken = false + + for (const step of section.steps) { + const base = {id: step.id, doc: step.doc, kind: step.kind} + if (step.kind !== 'auto' || !step.run) { + results.push({...base, status: 'skipped', durationMs: 0, note: step.reason}) + continue + } + if (chainBroken && !step.independent) { + results.push({...base, status: 'blocked', durationMs: 0, note: 'blocked by an earlier failure'}) + continue + } + const start = Date.now() + try { + const note = await step.run(ctx) + results.push({...base, status: 'pass', durationMs: Date.now() - start, note: note ?? undefined}) + ctx.log(`✅ ${step.id}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + results.push({...base, status: 'fail', durationMs: Date.now() - start, error: message}) + ctx.log(`❌ ${step.id}: ${message.split('\n')[0]}`) + if (!step.independent) chainBroken = true + } + } + sections.push({title: section.title, steps: results}) + } + + // Teardown: make sure no interactive process outlives the run. + for (const proc of ctx.state.ptyProcs) { + if (!proc.exited) proc.kill() + } + + let cliVersion = '' + try { + const result = await exec(ctx, ['version'], {timeoutMs: 60_000}) + cliVersion = extractVersion(result.stdout) + } catch { + // version already reported in the prep section; ignore here. + } + + const report: QAReport = { + startedAt, + finishedAt: new Date().toISOString(), + cliVersion, + cliTarget: ctx.cliTarget, + os: `${process.platform} ${process.arch}`, + sections, + } + + const outDir = process.env.QA_REPORT_DIR ?? path.join(process.cwd(), 'qa-report') + const {markdownPath, jsonPath} = writeReports(report, outDir) + ctx.log(`report: ${markdownPath} / ${jsonPath}`) + + const failed = sections.some((section) => section.steps.some((step) => step.status === 'fail' || step.status === 'blocked')) + process.exit(failed ? 1 : 0) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/packages/qa/src/steps/apps.ts b/packages/qa/src/steps/apps.ts new file mode 100644 index 00000000000..14a00cc1707 --- /dev/null +++ b/packages/qa/src/steps/apps.ts @@ -0,0 +1,313 @@ +/** + * "Apps" section of the CLI Pre-release QA flow. + * + * Each step mirrors one checklist item of the QA doc, in order. Items that + * require a human are declared kind: 'manual' and reported as skipped — never + * silently dropped. The whole `shopify app dev` block is manual by team + * decision: it is an interactive session with browser/visual checks. + */ +import * as fs from 'fs' +import * as path from 'path' +import {exec, expectSuccess, settle, spawnPty, tail} from '../proc.js' +import type {Ctx} from '../context.js' +import type {SectionDef} from '../types.js' + +const DEV_MANUAL_REASON = 'part of the interactive `shopify app dev` session — validated by hand during release QA' + +const FUNCTION_INPUT = + '{"cart":{"lines":[{"id":"gid://shopify/CartLine/0","cost":{"subtotalAmount":{"amount":"10.0"}}}]},"discount":{"discountClasses":["PRODUCT","ORDER","SHIPPING"]}}' + +const EXTENSIONS = { + adminAction: 'qa-admin-action', + theme: 'qa-theme-ext', + discount: 'qa-discount', + flowAction: 'qa-flow-action', + choice: 'qa-admin-block', +} + +function appDirOrThrow(ctx: Ctx): string { + const dir = ctx.state.appDir + if (!dir) throw new Error('No app directory (app init did not succeed)') + return dir +} + +async function generateExtension(ctx: Ctx, template: string, name: string, flavor?: string): Promise { + const appDir = appDirOrThrow(ctx) + const args = ['app', 'generate', 'extension', `--template=${template}`, `--name=${name}`] + if (flavor) args.push(`--flavor=${flavor}`) + expectSuccess( + await exec(ctx, args, {cwd: appDir, timeoutMs: 5 * 60_000}), + `app generate extension --template=${template}`, + ) + const extDir = path.join(appDir, 'extensions', name) + if (!fs.existsSync(extDir)) throw new Error(`Extension directory not created: ${extDir}`) + return `created extensions/${name}` +} + +export const appsSection: SectionDef = { + title: 'Apps', + steps: [ + { + id: 'apps.init', + doc: 'Create a new dev platform app: `shopify app init --template reactRouter`', + kind: 'auto', + run: async (ctx) => { + const name = `qa-ci-${new Date().toISOString().slice(0, 10)}-${process.pid}` + const args = [ + 'app', + 'init', + '--template', + 'reactRouter', + // Flags below only replace interactive prompts; the doc answers them by hand. + '--name', + name, + '--flavor', + 'javascript', + '--package-manager', + process.env.QA_PACKAGE_MANAGER ?? 'pnpm', + '--path', + ctx.workDir, + ] + if (ctx.orgId) args.push('--organization-id', ctx.orgId) + const result = expectSuccess(await exec(ctx, args, {timeoutMs: 10 * 60_000}), 'app init') + + const output = `${result.stdout}\n${result.stderr}` + const match = output.match(/([\w-]+) is ready for you to build!/) + let appDir: string | undefined + if (match?.[1]) { + appDir = path.join(ctx.workDir, match[1]) + } else { + const entries = fs.readdirSync(ctx.workDir, {withFileTypes: true}) + const appEntry = entries.find( + (entry) => entry.isDirectory() && fs.existsSync(path.join(ctx.workDir, entry.name, 'shopify.app.toml')), + ) + if (appEntry) appDir = path.join(ctx.workDir, appEntry.name) + } + if (!appDir || !fs.existsSync(appDir)) { + throw new Error(`Could not locate created app in ${ctx.workDir}\n${tail(output)}`) + } + // On CI, pnpm defaults to frozen-lockfile and `generate extension` adds + // workspace packages that are not in the template lockfile yet. + fs.appendFileSync(path.join(appDir, '.npmrc'), 'frozen-lockfile=false\n') + ctx.state.appDir = appDir + ctx.state.appName = name + return `created ${path.basename(appDir)}` + }, + }, + { + id: 'apps.ext.admin-action', + doc: '`shopify app generate extension --template=admin_action`', + kind: 'auto', + independent: true, + run: (ctx) => generateExtension(ctx, 'admin_action', EXTENSIONS.adminAction), + }, + { + id: 'apps.ext.theme', + doc: '`shopify app generate extension --template=theme_app_extension`', + kind: 'auto', + independent: true, + run: (ctx) => generateExtension(ctx, 'theme_app_extension', EXTENSIONS.theme), + }, + { + id: 'apps.ext.discount', + doc: '`shopify app generate extension --template=discount --flavor=typescript`', + kind: 'auto', + independent: true, + run: (ctx) => generateExtension(ctx, 'discount', EXTENSIONS.discount, 'typescript'), + }, + { + id: 'apps.ext.flow-action', + doc: '`shopify app generate extension --template=flow_action`', + kind: 'auto', + independent: true, + run: (ctx) => generateExtension(ctx, 'flow_action', EXTENSIONS.flowAction), + }, + { + id: 'apps.ext.choice', + doc: '`shopify app generate extension` (a random extension of your choice — admin_block)', + kind: 'auto', + independent: true, + run: (ctx) => generateExtension(ctx, 'admin_block', EXTENSIONS.choice), + }, + { + id: 'apps.dev.start', + doc: 'Run `shopify app dev`', + kind: 'manual', + reason: DEV_MANUAL_REASON, + }, + { + id: 'apps.dev.console', + doc: 'Dev Console: open the shop, see the dev console, app shows as connected (green icon)', + kind: 'manual', + reason: DEV_MANUAL_REASON, + }, + { + id: 'apps.dev.admin-action', + doc: 'Test the admin-action: product admin opens the action modal; editing `src/ActionExtension.js` hot reloads', + kind: 'manual', + reason: DEV_MANUAL_REASON, + }, + { + id: 'apps.dev.extension-mid-dev', + doc: 'Add another extension and see it show up in the dev console', + kind: 'manual', + reason: DEV_MANUAL_REASON, + }, + { + id: 'apps.dev.graphiql', + doc: 'Press `g` to open GraphiQL and test `query { shop { name } }`', + kind: 'manual', + reason: DEV_MANUAL_REASON, + }, + { + id: 'apps.dev.execute', + doc: "Test the same query via command: `shopify app execute --query 'query { shop { name } }'`", + kind: 'manual', + reason: DEV_MANUAL_REASON, + }, + { + id: 'apps.dev.theme-ext', + doc: 'Theme app extension: setup link, "Add section" + Save, local preview at 127.0.0.1:9292, `star_rating.liquid` hot reload', + kind: 'manual', + reason: DEV_MANUAL_REASON, + }, + { + id: 'apps.dev.quit', + doc: 'Press `q` to stop dev; dev console shows the preview as disconnected', + kind: 'manual', + reason: DEV_MANUAL_REASON, + }, + { + id: 'apps.dev.clean', + doc: 'Run `shopify app dev clean` to end the preview; the dev preview is now hidden', + kind: 'manual', + reason: DEV_MANUAL_REASON, + }, + { + id: 'apps.function.build', + doc: 'Move to the function directory and run `shopify app function build`', + kind: 'auto', + run: async (ctx) => { + const functionDir = path.join(appDirOrThrow(ctx), 'extensions', EXTENSIONS.discount) + expectSuccess( + await exec(ctx, ['app', 'function', 'build'], {cwd: functionDir, timeoutMs: 5 * 60_000}), + 'app function build', + ) + return 'function built' + }, + }, + { + id: 'apps.function.run', + doc: "echo '' | shopify app function run — should complete without an error", + kind: 'auto', + run: async (ctx) => { + const functionDir = path.join(appDirOrThrow(ctx), 'extensions', EXTENSIONS.discount) + const result = expectSuccess( + await exec(ctx, ['app', 'function', 'run'], { + cwd: functionDir, + input: FUNCTION_INPUT, + timeoutMs: 3 * 60_000, + }), + 'app function run', + ) + return `function ran (output tail: ${tail(result.stdout, 3).trim().slice(0, 120)})` + }, + }, + { + id: 'apps.deploy.v1', + doc: 'Run `shopify app deploy --version v1` and release the version — should complete without error', + kind: 'auto', + run: async (ctx) => { + // --allow-updates replaces the interactive confirmation (the flag the CLI + // documents as the CI/CD equivalent). Deploy releases by default. + expectSuccess( + await exec(ctx, ['app', 'deploy', '--version', 'v1', '--allow-updates'], { + cwd: appDirOrThrow(ctx), + timeoutMs: 10 * 60_000, + }), + 'app deploy --version v1', + ) + return 'v1 deployed and released' + }, + }, + { + id: 'apps.versions.list', + doc: 'Run `shopify app versions list` and validate that your version is there', + kind: 'auto', + run: async (ctx) => { + const result = expectSuccess( + await exec(ctx, ['app', 'versions', 'list'], {cwd: appDirOrThrow(ctx), timeoutMs: 2 * 60_000}), + 'app versions list', + ) + const output = result.stdout + result.stderr + if (!output.includes('v1')) throw new Error(`versions list does not contain v1:\n${tail(output, 20)}`) + return 'v1 present in versions list' + }, + }, + { + id: 'apps.config.link', + doc: 'Run `shopify app config link` and create a new app', + kind: 'auto', + run: async (ctx) => { + const appDir = appDirOrThrow(ctx) + const configName = 'staging' + const newAppName = `${ctx.state.appName ?? 'qa-ci-app'}-staging` + // --organization-id and --config replace prompts the doc answers by hand; + // the "create new app" + "app name" prompts are driven over the pty. + const args = ['app', 'config', 'link', '--config', configName] + if (ctx.orgId) args.push('--organization-id', ctx.orgId) + const proc = spawnPty(ctx, args, {cwd: appDir}) + + // First prompt is "Create this project as a new app" when the org has + // existing apps, or "App name" straight away when it does not. + await proc.waitFor(/Create this project as a new app|App name/, {timeoutMs: 3 * 60_000}) + if (proc.output().includes('Create this project as a new app')) { + await settle(100) + proc.write('\r') + await proc.waitFor(/App name/, {timeoutMs: 2 * 60_000}) + } + // Write the name as a single data event, then Enter separately so Ink + // treats it as submission (mirrors how a human types + confirms). + await settle(250) + proc.write(newAppName) + await settle(250) + proc.write('\r') + + const code = await proc.waitForExit(3 * 60_000) + if (code !== 0) throw new Error(`app config link exited with ${code}\n${tail(proc.output())}`) + const tomlPath = path.join(appDir, `shopify.app.${configName}.toml`) + if (!fs.existsSync(tomlPath)) { + throw new Error(`Expected ${tomlPath} to exist after config link\n${tail(proc.output())}`) + } + ctx.state.secondaryConfig = configName + return `created shopify.app.${configName}.toml linked to new app "${newAppName}"` + }, + }, + { + id: 'apps.deploy.second', + doc: 'Run `shopify app deploy` again and validate that it is deployed to the new app', + kind: 'auto', + run: async (ctx) => { + const appDir = appDirOrThrow(ctx) + const config = ctx.state.secondaryConfig + if (!config) throw new Error('No secondary config from `app config link`') + expectSuccess( + await exec(ctx, ['app', 'deploy', '--config', config, '--allow-updates'], { + cwd: appDir, + timeoutMs: 10 * 60_000, + }), + `app deploy --config ${config}`, + ) + const versions = expectSuccess( + await exec(ctx, ['app', 'versions', 'list', '--config', config], {cwd: appDir, timeoutMs: 2 * 60_000}), + 'app versions list --config', + ) + const output = versions.stdout + versions.stderr + if (!/1 app version|app versions?/i.test(output)) { + throw new Error(`Could not validate deploy on the new app:\n${tail(output, 20)}`) + } + return 'second deploy landed on the newly created app' + }, + }, + ], +} diff --git a/packages/qa/src/steps/hydrogen.ts b/packages/qa/src/steps/hydrogen.ts new file mode 100644 index 00000000000..50d42ca7483 --- /dev/null +++ b/packages/qa/src/steps/hydrogen.ts @@ -0,0 +1,135 @@ +import * as fs from 'fs' +import * as path from 'path' +import {exec, expectSuccess, freePort, httpRequest, retry, spawnPty, tail} from '../proc.js' +import type {PtyProcess} from '../proc.js' +import type {SectionDef} from '../types.js' + +/** Probe both address families — MiniOxygen may bind IPv6 ::1 or IPv4 127.0.0.1. */ +async function probeLocalhost(port: number): Promise<{status: number; body: string; url: string}> { + const urls = [`http://127.0.0.1:${port}/`, `http://[::1]:${port}/`] + let lastError: unknown + for (const url of urls) { + try { + // eslint-disable-next-line no-await-in-loop + const response = await httpRequest(url, {timeoutMs: 20_000}) + return {...response, url} + } catch (error) { + lastError = error + } + } + const message = + lastError instanceof Error ? (lastError.cause instanceof Error ? lastError.cause.message : lastError.message) : String(lastError) + throw new Error(`probe of port ${port} failed on IPv4 and IPv6: ${message}`) +} + +/** + * Press Enter on every prompt until `until` matches the output (the QA doc says + * "choose default answers to all questions"). A prompt is considered pending + * when new output containing a question indicator appeared and stayed quiet. + */ +async function answerDefaultsUntil(proc: PtyProcess, until: RegExp, timeoutMs: number): Promise { + const startedAt = Date.now() + let lastLength = 0 + let quietSince = Date.now() + let lastAnsweredLength = -1 + + while (Date.now() - startedAt < timeoutMs) { + if (until.test(proc.output())) return + if (proc.exited) { + if (until.test(proc.output())) return + throw new Error(`hydrogen init exited before completing\n${tail(proc.output())}`) + } + + const length = proc.output().length + if (length !== lastLength) { + lastLength = length + quietSince = Date.now() + } else if (Date.now() - quietSince > 2000 && length !== lastAnsweredLength) { + // Output has been quiet for 2s — if a prompt is on screen, accept the default. + const recent = proc.output().slice(-2000) + if (/\?|❯|›|Yes|No|\(y\/n\)/i.test(recent)) { + proc.write('\r') + lastAnsweredLength = length + } + } + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 250)) + } + throw new Error(`Timed out driving hydrogen init prompts\n${tail(proc.output())}`) +} + +function hydrogenDirOrThrow(ctx: {state: {hydrogenDir?: string}}): string { + const dir = ctx.state.hydrogenDir + if (!dir) throw new Error('No hydrogen project (hydrogen init did not succeed)') + return dir +} + +export const hydrogenSection: SectionDef = { + title: 'Hydrogen', + steps: [ + { + id: 'hydrogen.init', + doc: 'Create a Hydrogen app: `shopify hydrogen init` and choose default answers to all questions', + kind: 'auto', + run: async (ctx) => { + const dir = path.join(ctx.workDir, 'qa-hydrogen') + const proc = spawnPty(ctx, ['hydrogen', 'init', '--path', dir], {cwd: ctx.workDir}) + // "Storefront setup complete!" is the definitive end-of-init marker; the + // deps install between the last prompt and this box can take many minutes. + await answerDefaultsUntil(proc, /Storefront setup complete!|Next steps/, 20 * 60_000) + await proc.waitForExit(5 * 60_000) + if (!fs.existsSync(dir)) throw new Error(`Hydrogen project not created at ${dir}\n${tail(proc.output())}`) + ctx.state.hydrogenDir = dir + return 'hydrogen project scaffolded with default answers' + }, + }, + { + id: 'hydrogen.build', + doc: 'cd into the hydrogen project and run `shopify hydrogen build`', + kind: 'auto', + run: async (ctx) => { + expectSuccess( + await exec(ctx, ['hydrogen', 'build'], {cwd: hydrogenDirOrThrow(ctx), timeoutMs: 10 * 60_000}), + 'hydrogen build', + ) + return 'hydrogen build succeeded' + }, + }, + { + id: 'hydrogen.dev', + doc: 'Run `shopify hydrogen dev`; follow the "View Hydrogen app" link and confirm you can see a storefront', + kind: 'auto', + run: async (ctx) => { + // Pin the port so the probe is deterministic even if something else + // occupies hydrogen's default 3000. + const port = await freePort() + const proc = spawnPty(ctx, ['hydrogen', 'dev', '--port', String(port)], {cwd: hydrogenDirOrThrow(ctx)}) + await proc.waitFor(new RegExp(`View [\\w ]*app:|localhost:${port}`, 'i'), {timeoutMs: 5 * 60_000}) + const response = await retry(async () => { + const res = await probeLocalhost(port) + if (res.status >= 500) throw new Error(`storefront returned HTTP ${res.status}`) + return res + }, 10, 5000) + if (!/ proc.kill()) + return `storefront responded at ${response.url} (HTTP ${response.status}); stopped with CTRL+C` + }, + }, + ], +} + +export const crossOSSection: SectionDef = { + title: 'Linux & Windows', + steps: [ + { + id: 'cross-os.matrix', + doc: 'Run the same QA flow above for linux and windows platforms', + kind: 'manual', + reason: 'covered by the workflow OS matrix — rollout starts with macOS, linux/windows follow', + }, + ], +} diff --git a/packages/qa/src/steps/prep.ts b/packages/qa/src/steps/prep.ts new file mode 100644 index 00000000000..de63637422c --- /dev/null +++ b/packages/qa/src/steps/prep.ts @@ -0,0 +1,51 @@ +import {exec, expectSuccess} from '../proc.js' +import type {SectionDef} from '../types.js' + +/** Pull the semver out of `shopify version` output (it may be followed by an upgrade-notice box). */ +export function extractVersion(stdout: string): string { + const match = stdout.match(/^\s*(\d+\.\d+\.\d+(?:-[\w.]+)?)\s*$/m) + return match?.[1] ?? stdout.trim().split('\n')[0]?.trim() ?? '' +} + +export const prepSection: SectionDef = { + title: 'Preparation', + steps: [ + { + id: 'prep.release', + doc: 'Make a nightly release before starting the QA flow', + kind: 'manual', + reason: 'CI provides the artifact under test (repo build on CI runs, published nightly on manual runs)', + }, + { + id: 'prep.version', + doc: 'Run `shopify version`, the version should match the nightly you just created', + kind: 'auto', + run: async (ctx) => { + const result = expectSuccess(await exec(ctx, ['version'], {timeoutMs: 60_000}), 'shopify version') + const observed = extractVersion(result.stdout) + if (ctx.expectedVersion && observed !== ctx.expectedVersion) { + throw new Error(`Expected version ${ctx.expectedVersion}, got ${observed}`) + } + return `version ${observed}${ctx.expectedVersion ? ' (matches expected)' : ''}` + }, + }, + { + id: 'prep.review-changes', + doc: 'Review the changes in the new release and take detours in the QA steps to exercise them', + kind: 'manual', + reason: 'human judgement — review the Version Packages PR', + }, + ], +} + +export const generalSection: SectionDef = { + title: 'General', + steps: [ + { + id: 'general.lockdown', + doc: 'Turn off Shopify Lockdown (blocks adhoc-signed binaries via AMFI, used by functions)', + kind: 'manual', + reason: 'not applicable on CI runners (no Shopify Lockdown installed); required only on Shopify-issued Macs', + }, + ], +} diff --git a/packages/qa/src/steps/theme.ts b/packages/qa/src/steps/theme.ts new file mode 100644 index 00000000000..17829722958 --- /dev/null +++ b/packages/qa/src/steps/theme.ts @@ -0,0 +1,16 @@ +import type {SectionDef} from '../types.js' + +const REASON = + 'QA doc: "No need to run these anymore, themes have their own checklist" — owned by the themes team (#online-store-developer-platforms)' + +export const themeSection: SectionDef = { + title: 'Theme', + steps: [ + {id: 'theme.init', doc: 'Create a theme: `shopify theme init`', kind: 'delegated', reason: REASON}, + {id: 'theme.check', doc: 'Run `shopify theme check --fail-level crash`', kind: 'delegated', reason: REASON}, + {id: 'theme.package', doc: 'Run `shopify theme package`', kind: 'delegated', reason: REASON}, + {id: 'theme.dev', doc: 'Run `shopify theme dev --store ` (+ hot reload checks)', kind: 'delegated', reason: REASON}, + {id: 'theme.push', doc: 'Run `shopify theme push`', kind: 'delegated', reason: REASON}, + {id: 'theme.list', doc: 'Run `shopify theme list`', kind: 'delegated', reason: REASON}, + ], +} diff --git a/packages/qa/src/types.ts b/packages/qa/src/types.ts new file mode 100644 index 00000000000..c4804585a6b --- /dev/null +++ b/packages/qa/src/types.ts @@ -0,0 +1,59 @@ +import type {Ctx} from './context.js' + +/** + * How a QA-doc item is handled by the runner: + * - auto: executed by the runner. + * - manual: requires a human (browser/visual checks) — reported as skipped. + * - delegated: owned by another team per the QA doc (Theme section). + */ +export type StepKind = 'auto' | 'manual' | 'delegated' + +export type StepStatus = 'pass' | 'fail' | 'skipped' | 'blocked' + +export interface StepDef { + /** Stable id, e.g. "apps.init" */ + id: string + /** The QA-doc wording for this item (shown verbatim in the summary). */ + doc: string + kind: StepKind + /** For manual/delegated steps: why the runner skips it. */ + reason?: string + /** + * When true, this step runs even if a previous step in the section failed. + * Defaults to false: a failure blocks the remaining auto steps in the section. + */ + independent?: boolean + run?: (ctx: Ctx) => Promise +} + +export interface SectionDef { + /** QA-doc section title, e.g. "Apps". */ + title: string + steps: StepDef[] +} + +export interface StepResult { + id: string + doc: string + kind: StepKind + status: StepStatus + durationMs: number + /** Short human note (e.g. observed version, skip reason). */ + note?: string + /** Error tail for failures. */ + error?: string +} + +export interface SectionResult { + title: string + steps: StepResult[] +} + +export interface QAReport { + startedAt: string + finishedAt: string + cliVersion: string + cliTarget: string + os: string + sections: SectionResult[] +} diff --git a/packages/qa/tsconfig.json b/packages/qa/tsconfig.json new file mode 100644 index 00000000000..bb57fcfe385 --- /dev/null +++ b/packages/qa/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90357c9c908..6fbb68e4d86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -665,6 +665,27 @@ importers: specifier: ^3.2.7 version: 3.2.7(vitest@4.1.10) + packages/qa: + devDependencies: + '@types/node': + specifier: 18.19.130 + version: 18.19.130 + execa: + specifier: ^7.2.0 + version: 7.2.0 + node-pty: + specifier: ^1.0.0 + version: 1.1.0 + strip-ansi: + specifier: ^7.2.0 + version: 7.2.0 + tsx: + specifier: ^4.23.1 + version: 4.23.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + packages/store: dependencies: '@graphql-typed-document-node/core': @@ -862,48 +883,56 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@ast-grep/napi-linux-arm64-gnu@0.43.0': resolution: {integrity: sha512-yJSRPxwwrvVW94J2rtaatcixSAGWcSaHNbAh6soXD6HXgq6I7uMc+cyMnJstFL789yd6Pu3QIhTlpD9VY0oYhw==, tarball: https://registry.npmjs.org/@ast-grep/napi-linux-arm64-gnu/-/napi-linux-arm64-gnu-0.43.0.tgz} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@ast-grep/napi-linux-arm64-musl@0.34.1': resolution: {integrity: sha512-IXdqwTbkdqHrcuQb448Qzd82QdTqVFe/f0sSkFYQTic8P2qNzmiHsVnxgEFsQPPbe09BVAoZ885j3OnaNfcDYA==, tarball: https://registry.npmjs.org/@ast-grep/napi-linux-arm64-musl/-/napi-linux-arm64-musl-0.34.1.tgz} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@ast-grep/napi-linux-arm64-musl@0.43.0': resolution: {integrity: sha512-mknXLDsf66HvT/JEl18ZQSvR7/qWgWfqh3eHuVRqD2lE6cKBDXRnAzx9ZNZkUnL2Z5ph54Yk8dKVu09k45cegA==, tarball: https://registry.npmjs.org/@ast-grep/napi-linux-arm64-musl/-/napi-linux-arm64-musl-0.43.0.tgz} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@ast-grep/napi-linux-x64-gnu@0.34.1': resolution: {integrity: sha512-on4LyIeN/zN7SIh8zr5v+NTzVu3kXm2mG28ib1Qe9GVcf35dz52ckf7bilulayKSa2MHZWAXMjuc6NYMiNEw+w==, tarball: https://registry.npmjs.org/@ast-grep/napi-linux-x64-gnu/-/napi-linux-x64-gnu-0.34.1.tgz} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@ast-grep/napi-linux-x64-gnu@0.43.0': resolution: {integrity: sha512-KM6M5KKFsHG9Y7VKCKnMsWQQ1sYwj/SPdyr91SKp66AeFJ5xMtXb13WVQ3Joe9NEsi84dzzOBIJgMddz+UMvQw==, tarball: https://registry.npmjs.org/@ast-grep/napi-linux-x64-gnu/-/napi-linux-x64-gnu-0.43.0.tgz} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@ast-grep/napi-linux-x64-musl@0.34.1': resolution: {integrity: sha512-l1R5L9LOp0jTPjs8C+LUndZOA8cRw7PFlvoVxVbi2jCfcns00dqatSYc4yA/ke6ng2K0LSxjoV/jS8tefve0sA==, tarball: https://registry.npmjs.org/@ast-grep/napi-linux-x64-musl/-/napi-linux-x64-musl-0.34.1.tgz} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@ast-grep/napi-linux-x64-musl@0.43.0': resolution: {integrity: sha512-NfjI74m7CEEOsLi7ZkYwcxY10CfKIHPHRrr9aqMulmnrC4FGvxQMk1qpDrTqkjmEd8LdZt3PsPrmBa8AZCErew==, tarball: https://registry.npmjs.org/@ast-grep/napi-linux-x64-musl/-/napi-linux-x64-musl-0.43.0.tgz} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@ast-grep/napi-win32-arm64-msvc@0.34.1': resolution: {integrity: sha512-eVsdMtnY7jmN2xQjYY9gaqIxRHA44+QYivlP1uLbg8w3P4YlZWTFgOJ7aa357Hg/257mjeQCpodCkr0lGRsSYQ==, tarball: https://registry.npmjs.org/@ast-grep/napi-win32-arm64-msvc/-/napi-win32-arm64-msvc-0.34.1.tgz} @@ -3007,21 +3036,25 @@ packages: resolution: {integrity: sha512-CfzWaS+b32lI/inlYPv1ZAK9CWTWFHzT7TPThvOHML65nrgFl8cQ4Z4FeGdyCbHu2NoG3vR1E36O2tPI2/DLGg==, tarball: https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.7.7.tgz} cpu: [arm64] os: [linux] + libc: [glibc] '@nx/nx-linux-arm64-musl@22.7.7': resolution: {integrity: sha512-53QFuODMEHc1gobCT2WOmYz89DZtEIuLphirdIgnXkkPBTdrP77LPbJUXMRXCMKr90BQaSWhTX+J/ubANRE8og==, tarball: https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.7.7.tgz} cpu: [arm64] os: [linux] + libc: [musl] '@nx/nx-linux-x64-gnu@22.7.7': resolution: {integrity: sha512-unzmqGIDXGzujo1ZtbrMj2e5D5ldQ1feZmqDPhvO4casK2d96jpT8LtgIILpVDUfhLjl+By185gb0ArrWTsyKw==, tarball: https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.7.7.tgz} cpu: [x64] os: [linux] + libc: [glibc] '@nx/nx-linux-x64-musl@22.7.7': resolution: {integrity: sha512-oTjMGuM105ok8aH5rlg2YP0Kgkjg0VfUj1exwp49S70gGBJ0r8v5rEtJwOSdCf1WTHuEh0xi66Y/b1S0khF8dg==, tarball: https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.7.7.tgz} cpu: [x64] os: [linux] + libc: [musl] '@nx/nx-win32-arm64-msvc@22.7.7': resolution: {integrity: sha512-K741meG/l48TPPeDqnhYTV7pP+1ljjZ+0DRJBxMrPFIu8qKE3Abko/7NKWmWRjcSXJvtxvYkgG3wZds4N2Bhow==, tarball: https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.7.7.tgz} @@ -3327,41 +3360,49 @@ packages: resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==, tarball: https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.20.0.tgz} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-arm64-musl@11.20.0': resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==, tarball: https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.20.0.tgz} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==, tarball: https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.20.0.tgz} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==, tarball: https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.20.0.tgz} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==, tarball: https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.20.0.tgz} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==, tarball: https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.20.0.tgz} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-gnu@11.20.0': resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==, tarball: https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.20.0.tgz} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.20.0': resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==, tarball: https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.20.0.tgz} cpu: [x64] os: [linux] + libc: [musl] '@oxc-resolver/binding-openharmony-arm64@11.20.0': resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==, tarball: https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.20.0.tgz} @@ -3412,36 +3453,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==, tarball: https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==, tarball: https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz} @@ -3565,66 +3612,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==, tarball: https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz} @@ -4138,41 +4198,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz}