diff --git a/.changeset/config.json b/.changeset/config.json index 9cf10992e..8193ef40a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,12 +1,11 @@ { - "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", - "changelog": false, - "commit": false, - "fixed": [], - "linked": [], - "access": "public", - "baseBranch": "master", - "updateInternalDependencies": "patch", - "ignore": ["docs", "@upup/landing", "@upup/playground"], - "packages": ["packages/*"] + "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", + "changelog": false, + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "master", + "updateInternalDependencies": "patch", + "ignore": ["@upupjs/docs", "@upupjs/landing", "@upupjs/playground"] } diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..ccd3eae6f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Husky hooks are sh scripts parsed line-by-line by knip and executed by sh — +# a CRLF checkout (Windows core.autocrlf) makes knip extract binary names with +# a trailing \r ("lint-staged\r"), failing the gate on any Windows clone (N7). +.husky/* text eol=lf + +# Drive-sandbox test fixtures are byte-exact assets: the live cloud-drive +# integration suite uploads them and hash-checks the round-tripped bytes, so no +# platform EOL normalization may touch them. +scripts/drive-sandbox/fixtures/** binary diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..b5245214b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +version: 2 +updates: + # Published surface + workspace deps. `npm` ecosystem reads pnpm-lock.yaml (v9). + - package-ecosystem: 'npm' + directories: + - '/' + - '/packages/*' + schedule: + interval: 'weekly' + day: 'monday' + open-pull-requests-limit: 5 + groups: + production-dependencies: # prioritise the shipped prod deps, one PR + applies-to: version-updates + dependency-type: 'production' + update-types: ['minor', 'patch'] + development-dependencies: + applies-to: version-updates + dependency-type: 'development' + update-types: ['minor', 'patch'] + ignore: + # Majors are DEFERRED (F-189: Angular 19->22, Storybook 9->10, Tailwind + # 3->4, TS 5->6, Vite/Rollup/ESLint/jsdom). See audit/deferred-roadmap.md. + - dependency-name: '*' + update-types: ['version-update:semver-major'] + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'weekly' + groups: + github-actions: + patterns: ['*'] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 85e8d3196..54ed26933 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -21,6 +21,16 @@ Fixes # (issue) - [ ] I have performed a self-review of my own code - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings -- [ ] I have added tests that prove my fix is effective or that my feature works -- [ ] I have updated the `package.json` file with the new version -- [ ] I have updated the `CHANGELOG.md` file for the new version +- [ ] I have added tests that prove my fix is effective or that my feature works, with behavior-driven names (see `docs/testing.md`) +- [ ] I have added a changeset (`pnpm changeset`) if this PR changes any publishable `@upupjs/*` package + +## Test-foundation checklist (delete rows that cannot apply) + +- [ ] UI change: landed in `@upupjs/react` first, ported DOM-identically, and `parity-fixtures.json` was regenerated via `UPDATE_PARITY=1` + reviewed like code (never in CI) +- [ ] New/renamed story: name, id, args, and variants stay consistent across all six framework storybooks (or the divergence is documented where it lives) +- [ ] Public export changed: the affected package's `public-api` pin was updated deliberately (core: also `internal-surface`) +- [ ] Packaging/dependency change: `pnpm run smoke:packages` passes locally +- [ ] New test suite or moved test directory: the impact map in `scripts/ci/resolve-affected-tests.mjs` still routes it (add a rule + table-driven test if not) +- [ ] New env var: added to the relevant `local-dev/*.example` file and `scripts/validate-env.mjs` schema (`pnpm run env:check`) +- [ ] New third-party integration: sandbox/test credentials documented in `docs/testing.md` (names, scopes, callback URLs) — never production credentials +- [ ] No new `.only`, silent skips, unjustified sleeps, or integration-layer mocks (`pnpm run test:quality`) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..7e59f40c9 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,213 @@ +name: E2E + +permissions: + contents: read + +on: + pull_request: + branches: [master, dev] + workflow_dispatch: + +# Actions pinned to full commit SHAs (F-792); pnpm via pnpm/action-setup + +# setup-node cache:pnpm (F-787) — same mechanism as main.yml / publish.yml. + +# Affected-test routing: Resolve-Affected classifies the PR's changed files +# via scripts/ci/resolve-affected-tests.mjs (the impact map lives IN CODE and +# is unit-tested by `pnpm run test:scripts`). Heavy jobs skip only when the +# resolver proves them unaffected; an unmatched path fails open to every +# suite, and the E2E-Status rollup fails on any skip the resolver did NOT +# sanction — so branch protection semantics are preserved. main.yml is +# deliberately unrouted (static + unit gates always run). + +jobs: + Resolve-Affected: + name: Resolve affected suites + runs-on: ubuntu-latest + outputs: + e2e: ${{ steps.resolve.outputs.e2e }} + minio: ${{ steps.resolve.outputs.minio }} + smoke: ${{ steps.resolve.outputs.smoke }} + steps: + - name: Checkout the repository (full history for merge-base diff) + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + + - name: Resolve affected suites (zero-dep script, no install) + id: resolve + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + node scripts/ci/resolve-affected-tests.mjs --all + else + node scripts/ci/resolve-affected-tests.mjs \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.event.pull_request.head.sha }}" + fi + + E2E: + name: Cross-framework E2E (Playwright + MinIO) + runs-on: ubuntu-latest + needs: [Resolve-Affected] + if: needs.Resolve-Affected.outputs.e2e == 'true' || needs.Resolve-Affected.outputs.minio == 'true' + steps: + - name: Checkout the repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + run: pnpm --filter @upupjs/e2e-test exec playwright install --with-deps chromium + + - name: Provision MinIO env (defaults; smoke is OAuth-free) + run: cp local-dev/.env.minio.example local-dev/.env.minio + + - name: Guard against fixture/baseline regen in CI (F-205) + run: if [ -n "$UPDATE_PARITY" ] || [ -n "$UPDATE_A11Y_BASELINE" ]; then echo "regen env forbidden in CI"; exit 1; fi + + - name: Run e2e gate (deep React + cross-framework smoke) + run: pnpm run e2e + env: + MINIO_ROOT_USER: upupadmin + MINIO_ROOT_PASSWORD: upupadmin123 + UPUP_UPLOAD_TOKEN_SECRET: upup-e2e-upload-token-secret-dev-only + UPUP_E2E_BUCKET: upup-e2e + UPUP_E2E_REGION: us-east-1 + UPUP_E2E_ENDPOINT: http://localhost:9100 + + # The strongest adversarial verification in the repo (forged-token 403s, + # oversized-PUT rejection, multipart size-envelope abort, sha256 byte + # round-trips) lives in @upupjs/server tests/integration and is gated on + # UPUP_E2E_MINIO=1 (provided by the env file via the dotenv wrapper). + # Without these steps it silently skips everywhere except a dev laptop. + # Runs after the e2e gate because that step builds the workspace + # (server's vitest resolves @upupjs/core via dist). MinIO must be booted + # HERE: the cf global-teardown stops the compose stack its own setup + # started, so nothing from the e2e gate survives into this step + # (proven by an ECONNREFUSED :9100 on this step's first CI execution). + - name: Boot MinIO for the integration suites + if: needs.Resolve-Affected.outputs.minio == 'true' + run: | + pnpm run e2e:minio:up + for i in $(seq 1 45); do + if curl -sf http://localhost:9100/minio/health/live > /dev/null; then + echo "MinIO healthy on :9100"; exit 0 + fi + sleep 2 + done + echo "MinIO failed to become healthy on :9100" >&2 + exit 1 + + - name: Server trust-model + byte-integrity suites (real MinIO) + if: needs.Resolve-Affected.outputs.minio == 'true' + run: pnpm run e2e:minio:test + + # Visual layer: the suites freeze named product states as PNGs + # (apps/e2e-test/visual/product-state-screenshots.ts). Uploaded on + # success AND failure — they are review evidence now and the + # snapvisor.io (Argos-style) diff input once its uploader is wired + # into this job. An empty upload warns: e2e ran but captured + # nothing, which means the capture layer itself broke. + - name: Upload visual product-state screenshots + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: e2e-visual-screenshots + path: apps/e2e-test/screenshots/ + if-no-files-found: warn + retention-days: 14 + + - name: Upload Playwright traces and reports + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: e2e-playwright-artifacts + path: | + apps/e2e-test/playwright-report/ + apps/e2e-test/test-results/ + retention-days: 14 + + Smoke-Packages: + name: Package smoke (real tarball consumer) + runs-on: ubuntu-latest + needs: [Resolve-Affected] + if: needs.Resolve-Affected.outputs.smoke == 'true' + steps: + - name: Checkout the repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Package smoke (packs all 9, isolated consumer, vite build, dist-shape asserts) + run: pnpm run smoke:packages # self-builds via build:package; no browser, no MinIO + + # Rollup aggregator for this workflow (F-780): main.yml's Status-Check spans + # only main.yml jobs, so E2E + Smoke-Packages were aggregated by nothing. + # Branch protection must require BOTH this job AND main.yml's Status Check. + # Routing-aware: a job may be SKIPPED only when Resolve-Affected proved it + # unaffected; any other non-success (including a skip caused by a failed + # resolver) fails the rollup, so routing can never hide required coverage. + E2E-Status: + name: E2E Status Check + runs-on: ubuntu-latest + needs: [Resolve-Affected, E2E, Smoke-Packages] + if: always() + steps: + - name: Check status (respecting resolver-sanctioned skips) + env: + RESOLVE_RESULT: ${{ needs.Resolve-Affected.result }} + E2E_RESULT: ${{ needs.E2E.result }} + E2E_WANTED: ${{ needs.Resolve-Affected.outputs.e2e == 'true' || needs.Resolve-Affected.outputs.minio == 'true' }} + SMOKE_RESULT: ${{ needs.Smoke-Packages.result }} + SMOKE_WANTED: ${{ needs.Resolve-Affected.outputs.smoke }} + run: | + fail=0 + if [ "$RESOLVE_RESULT" != "success" ]; then + echo "Resolve-Affected did not succeed ($RESOLVE_RESULT) — cannot trust any skip" + fail=1 + fi + check() { + name="$1"; result="$2"; wanted="$3" + if [ "$wanted" = "true" ]; then + if [ "$result" != "success" ]; then + echo "$name is required for this change set but finished '$result'" + fail=1 + fi + else + if [ "$result" != "skipped" ] && [ "$result" != "success" ]; then + echo "$name was not required but finished '$result'" + fail=1 + fi + fi + } + check "E2E" "$E2E_RESULT" "$E2E_WANTED" + check "Smoke-Packages" "$SMOKE_RESULT" "$SMOKE_WANTED" + if [ "$fail" -ne 0 ]; then + echo "E2E status check failed" + exit 1 + fi + echo "All required E2E jobs passed (unaffected suites legitimately skipped)" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a7a86a4ed..939028283 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,245 +1,293 @@ name: CI permissions: - contents: read + contents: read on: - pull_request: - branches: [master, dev] + pull_request: + branches: [master, dev] + workflow_dispatch: + +# Actions are pinned to full commit SHAs (F-792) to match docker-compose's +# digest-pinning posture; the trailing comment records the human version. +# Dependabot's github-actions group updates the SHA pins weekly. +# pnpm is bootstrapped via pnpm/action-setup (reads packageManager from +# package.json) + setup-node cache:pnpm — one mechanism across all workflows +# (F-787). Do not reintroduce the hardcoded `npm install -g pnpm@x` bootstrap. jobs: - Prettier-check: - name: Prettier Check - runs-on: ubuntu-latest - steps: - - name: Checkout the repository - uses: actions/checkout@v3 - - - name: Setup Node.js environment - uses: actions/setup-node@v3 - id: cache-primes - with: - node-version-file: '.nvmrc' - - - name: Setup PNPM environment - if: steps.cache-primes.outputs.cache-hit != 'true' - run: | - npm install -g pnpm@latest - pnpm config set store-dir ~/.pnpm-store - - - name: Cache PNPM dependencies - uses: actions/cache@v3 - with: - path: ~/.pnpm-store - key: ${{ runner.os }}-pnpm-v3-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-v3- - - - name: Install dependencies - if: steps.cache-primes.outputs.cache-hit != 'true' - run: pnpm install - - - name: Prettier Check - run: pnpm run prettier-check - - Test: - name: Run Tests and Coverage - runs-on: ubuntu-latest - needs: [Prettier-check] - steps: - - name: Checkout repo - uses: actions/checkout@v3 - - - uses: actions/setup-node@v3 - id: cache-primes - with: - node-version-file: '.nvmrc' - - - name: Setup PNPM environment - if: steps.cache-primes.outputs.cache-hit != 'true' - run: | - npm install -g pnpm@latest - pnpm config set store-dir ~/.pnpm-store - - - name: Cache PNPM dependencies - uses: actions/cache@v3 - with: - path: ~/.pnpm-store - key: ${{ runner.os }}-pnpm-v3-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-v3- - - - name: Install dependencies - if: steps.cache-primes.outputs.cache-hit != 'true' - run: pnpm install - - - name: Run Tests with Coverage - run: pnpm run test:coverage - - - name: Check Coverage Thresholds - run: | - COVERAGE_OUTPUT=$(cat coverage/coverage-summary.json) - - STATEMENTS_PCT=$(echo $COVERAGE_OUTPUT | jq -r '.total.statements.pct') - BRANCHES_PCT=$(echo $COVERAGE_OUTPUT | jq -r '.total.branches.pct') - FUNCTIONS_PCT=$(echo $COVERAGE_OUTPUT | jq -r '.total.functions.pct') - LINES_PCT=$(echo $COVERAGE_OUTPUT | jq -r '.total.lines.pct') - - THRESHOLD=1 - - if (( $(echo "$STATEMENTS_PCT < $THRESHOLD" | bc -l) )); then - echo "❌ Statement coverage ($STATEMENTS_PCT%) is below threshold ($THRESHOLD%)" - exit 1 - fi - - if (( $(echo "$BRANCHES_PCT < $THRESHOLD" | bc -l) )); then - echo "❌ Branch coverage ($BRANCHES_PCT%) is below threshold ($THRESHOLD%)" - exit 1 - fi - - if (( $(echo "$FUNCTIONS_PCT < $THRESHOLD" | bc -l) )); then - echo "❌ Function coverage ($FUNCTIONS_PCT%) is below threshold ($THRESHOLD%)" - exit 1 - fi - - if (( $(echo "$LINES_PCT < $THRESHOLD" | bc -l) )); then - echo "❌ Line coverage ($LINES_PCT%) is below threshold ($THRESHOLD%)" - exit 1 - fi - - echo "✅ All coverage thresholds met!" - echo "Statements: $STATEMENTS_PCT%" - echo "Branches: $BRANCHES_PCT%" - echo "Functions: $FUNCTIONS_PCT%" - echo "Lines: $LINES_PCT%" - - - name: Archive code coverage results - uses: actions/upload-artifact@v4 - with: - name: code-coverage-report - path: coverage/ - retention-days: 30 - - Build: - name: Build project - runs-on: ubuntu-latest - needs: [Prettier-check, Test, E2E] - steps: - - name: Checkout repo - uses: actions/checkout@v3 - - - uses: actions/setup-node@v3 - id: cache-primes - with: - node-version-file: '.nvmrc' - - - name: Setup PNPM environment - if: steps.cache-primes.outputs.cache-hit != 'true' - run: | - npm install -g pnpm@latest - pnpm config set store-dir ~/.pnpm-store - - - name: Cache PNPM dependencies - uses: actions/cache@v3 - with: - path: ~/.pnpm-store - key: ${{ runner.os }}-pnpm-v3-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-v3- - - - name: Install dependencies - if: steps.cache-primes.outputs.cache-hit != 'true' - run: pnpm install - - - name: Build - run: pnpm run build - - Status-Check: - name: Status Check - runs-on: ubuntu-latest - needs: [Prettier-check, Test, E2E, Build] - if: always() - steps: - - name: Check status - run: | - if [ "${{ needs.Prettier-check.result }}" != "success" ] || \ - [ "${{ needs.Test.result }}" != "success" ] || \ - [ "${{ needs.E2E.result }}" != "success" ] || \ - [ "${{ needs.Build.result }}" != "success" ]; then - echo "One or more jobs failed" - exit 1 - fi - echo "All jobs passed successfully" - - E2E: - name: Playwright E2E - runs-on: ubuntu-latest - needs: [Prettier-check, Test] - timeout-minutes: 30 - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - id: cache-primes - with: - node-version-file: '.nvmrc' - - - name: Setup PNPM environment - if: steps.cache-primes.outputs.cache-hit != 'true' - run: | - npm install -g pnpm@latest - pnpm config set store-dir ~/.pnpm-store - - - name: Cache PNPM dependencies - uses: actions/cache@v3 - with: - path: ~/.pnpm-store - key: ${{ runner.os }}-pnpm-v3-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-v3- - - - name: Install deps - if: steps.cache-primes.outputs.cache-hit != 'true' - run: pnpm install - - - name: Install Playwright Browsers - run: pnpm run playwright:install - - - name: Start backend (dev server) - env: - PORT: 53010 - S3_BUCKET: ${{ secrets.S3_BUCKET }} - S3_REGION: ${{ secrets.S3_REGION }} - S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} - S3_KEY_ID: ${{ secrets.S3_KEY_ID }} - S3_SECRET: ${{ secrets.S3_SECRET }} - run: | - pnpm run dev:server & - echo "Waiting for backend health..." - for i in {1..60}; do - if curl -sf http://localhost:53010/health >/dev/null; then echo OK; break; fi - sleep 2 - done - - - name: Run Playwright tests - env: - # Storybook is started by Playwright's webServer config on port 6007 - TOKEN_ENDPOINT: http://localhost:53010/api/upload - GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} - GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} - GOOGLE_APP_ID: ${{ secrets.GOOGLE_APP_ID }} - ONEDRIVE_CLIENT_ID: ${{ secrets.ONEDRIVE_CLIENT_ID }} - DROPBOX_CLIENT_ID: ${{ secrets.DROPBOX_CLIENT_ID }} - DROPBOX_APP_SECRET: ${{ secrets.DROPBOX_APP_SECRET }} - DROPBOX_REDIRECT_URI: ${{ secrets.DROPBOX_REDIRECT_URI }} - run: pnpm run test:e2e --reporter=list - - - name: Upload Playwright report + Prettier-check: + name: Prettier Check + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prettier Check + run: pnpm run prettier-check + + Test: + name: Run Tests and Coverage + runs-on: ubuntu-latest + needs: [Prettier-check] + steps: + - name: Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Test-quality guard # committed .only, silent skips, vague names, unjustified sleeps, integration-layer mocks, regen-guard presence + run: pnpm run test:quality + + - name: Script self-tests (quality guard, affected resolver, tarball lib) + run: pnpm run test:scripts + + - name: Run all package unit suites # F-163 / F-502 — every package, not just react + run: pnpm run test # after P1 === `turbo run test --continue` + + - name: Coverage floors (all 9 publishable packages) # F-162 / F-503 — per-package anti-regression ratchets in each vitest.config.ts; turbo --continue so one package cannot mask the rest (F-790: routed through turbo so preact/vanilla see fresh dist) + run: pnpm run test:coverage + + - name: Env schema drift + run: pnpm run env:check + + - name: Retired-vocabulary census # naming sweeps must land in EVERY layer (identifiers, DOM strings, fixtures) — see scripts/check-retired-vocab.mjs + run: pnpm run vocab:check + + - name: Archive code coverage results + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: code-coverage-report + path: coverage/ + retention-days: 30 + + Build: + name: Build project + runs-on: ubuntu-latest + needs: [Prettier-check, Test] + steps: + - name: Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + env: + MINIO_ROOT_USER: upupadmin + MINIO_ROOT_PASSWORD: upupadmin123 + UPUP_UPLOAD_TOKEN_SECRET: upup-e2e-upload-token-secret-dev-only + UPUP_E2E_BUCKET: upup-e2e + UPUP_E2E_REGION: us-east-1 + UPUP_E2E_ENDPOINT: http://localhost:9100 + + Typecheck: + name: Typecheck project + runs-on: ubuntu-latest + needs: [Prettier-check] + steps: + - name: Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm run typecheck + + Bundle-Size: + name: Bundle size check + runs-on: ubuntu-latest + needs: [Build] + steps: + - name: Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + env: + MINIO_ROOT_USER: upupadmin + MINIO_ROOT_PASSWORD: upupadmin123 + UPUP_UPLOAD_TOKEN_SECRET: upup-e2e-upload-token-secret-dev-only + UPUP_E2E_BUCKET: upup-e2e + UPUP_E2E_REGION: us-east-1 + UPUP_E2E_ENDPOINT: http://localhost:9100 + + - name: Size limit + run: pnpm run size + + Dependency-Audit: + name: Dependency audit (packages/*, production scope) + runs-on: ubuntu-latest + needs: [Prettier-check] + steps: + - name: Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prod dependency audit (publishable packages, fail on high+) + run: pnpm run audit:prod + + Lint: + name: Lint + runs-on: ubuntu-latest + needs: [Prettier-check] + steps: + - name: Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint + + Oxlint: + name: Oxlint (fast lint) + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Oxlint + run: pnpm run lint:ox + + Knip: + name: Knip (dead code) + runs-on: ubuntu-latest + needs: [Prettier-check] + steps: + - name: Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Knip + run: pnpm run knip + + Status-Check: + name: Status Check + runs-on: ubuntu-latest + needs: + [ + Prettier-check, + Oxlint, + Test, + Build, + Typecheck, + Bundle-Size, + Dependency-Audit, + Lint, + Knip, + ] if: always() - uses: actions/upload-artifact@v4 - with: - name: playwright-report - path: | - playwright-report - test-results - retention-days: 7 + steps: + - name: Check status + run: | + if [ "${{ needs.Prettier-check.result }}" != "success" ] || \ + [ "${{ needs.Oxlint.result }}" != "success" ] || \ + [ "${{ needs.Test.result }}" != "success" ] || \ + [ "${{ needs.Build.result }}" != "success" ] || \ + [ "${{ needs.Typecheck.result }}" != "success" ] || \ + [ "${{ needs.Bundle-Size.result }}" != "success" ] || \ + [ "${{ needs.Dependency-Audit.result }}" != "success" ] || \ + [ "${{ needs.Lint.result }}" != "success" ] || \ + [ "${{ needs.Knip.result }}" != "success" ]; then + echo "One or more jobs failed" + exit 1 + fi + echo "All jobs passed successfully" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..d09502fa4 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,358 @@ +name: Nightly + +permissions: + contents: read + +# Deep suites too slow or too external for every PR: the full e2e gate plus +# the a11y/overflow sweep, static storybook builds, the tarball consumer, and +# the mastra LLM evals (paid key; loudly skipped when the secret is absent — +# never a silent green). PR routing lives in e2e.yml via +# scripts/ci/resolve-affected-tests.mjs; nightly always runs everything. +# Actions pinned to full commit SHAs (F-792); pnpm via pnpm/action-setup + +# setup-node cache:pnpm (F-787) — same mechanism as main.yml / e2e.yml. + +on: + schedule: + - cron: '17 3 * * *' # 03:17 UTC daily + workflow_dispatch: + +jobs: + E2E-Full: + name: Full E2E + a11y sweep (Playwright + MinIO) + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + run: pnpm --filter @upupjs/e2e-test exec playwright install --with-deps chromium + + - name: Provision MinIO env (defaults; suites are OAuth-free) + run: cp local-dev/.env.minio.example local-dev/.env.minio + + - name: Guard against fixture/baseline regen in CI (F-205) + run: if [ -n "$UPDATE_PARITY" ] || [ -n "$UPDATE_A11Y_BASELINE" ]; then echo "regen env forbidden in CI"; exit 1; fi + + - name: Run e2e gate (deep React + cross-framework) + run: pnpm run e2e + env: + MINIO_ROOT_USER: upupadmin + MINIO_ROOT_PASSWORD: upupadmin123 + UPUP_UPLOAD_TOKEN_SECRET: upup-e2e-upload-token-secret-dev-only + UPUP_E2E_BUCKET: upup-e2e + UPUP_E2E_REGION: us-east-1 + UPUP_E2E_ENDPOINT: http://localhost:9100 + + - name: Boot MinIO for the integration suites # cf teardown stops its own compose stack — see e2e.yml + run: | + pnpm run e2e:minio:up + for i in $(seq 1 45); do + if curl -sf http://localhost:9100/minio/health/live > /dev/null; then + echo "MinIO healthy on :9100"; exit 0 + fi + sleep 2 + done + echo "MinIO failed to become healthy on :9100" >&2 + exit 1 + + - name: Server trust-model + byte-integrity suites (real MinIO) + run: pnpm run e2e:minio:test + + - name: A11y ratchet + overflow sweep (six storybooks, serial) + run: pnpm run e2e:a11y + + # Visual layer: same capture set as e2e.yml (parity mount/populated + # states x6 frameworks, real-upload success x6, deep-react product + # states) — nightly's copy is the unrouted full-breadth run and the + # future snapvisor.io baseline feed. + - name: Upload visual product-state screenshots + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: nightly-visual-screenshots + path: apps/e2e-test/screenshots/ + if-no-files-found: warn + retention-days: 14 + + - name: Upload Playwright traces and reports + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: nightly-playwright-artifacts + path: | + apps/e2e-test/playwright-report/ + apps/e2e-test/test-results/ + retention-days: 14 + + Storybook-Builds: + name: Static storybook builds (all six frameworks) + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build every storybook statically # zero PR-CI callers — nightly catches build rot + run: pnpm run build:storybook + + Smoke-Packages: + name: Package smoke (real tarball consumer) + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Package smoke (packs all 9, isolated consumer, vite build, dist-shape asserts) + run: pnpm run smoke:packages + + Mastra-Evals: + name: Mastra LLM evals (paid key; explicit skip without it) + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Detect eval credentials + id: creds + env: + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + run: | + if [ -n "$OPENROUTER_API_KEY" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice title=Mastra evals skipped::OPENROUTER_API_KEY secret is not configured — the LLM eval suite did NOT run. Deterministic mastra tests still ran in main.yml's unit gate." + echo "## Mastra evals: SKIPPED (no OPENROUTER_API_KEY secret)" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Setup pnpm + if: steps.creds.outputs.present == 'true' + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + if: steps.creds.outputs.present == 'true' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + if: steps.creds.outputs.present == 'true' + run: pnpm install --frozen-lockfile + + - name: Boot mastra dev server and run the canned-prompt evals + if: steps.creds.outputs.present == 'true' + env: + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + run: | + pnpm --filter @upupjs/mastra dev & + MASTRA_PID=$! + for i in $(seq 1 60); do + if curl -sf http://localhost:4111/healthz > /dev/null; then + echo "mastra dev healthy on :4111"; break + fi + if [ "$i" = "60" ]; then + echo "mastra dev failed to become healthy" >&2; kill $MASTRA_PID || true; exit 1 + fi + sleep 2 + done + set +e + pnpm --filter @upupjs/mastra eval + EVAL_EXIT=$? + set -e + kill $MASTRA_PID || true + exit $EVAL_EXIT + + Drive-Sandbox: + name: Cloud-drive live integration (sandbox creds; explicit skip without them) + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Detect drive sandbox credentials + id: creds + env: + UPUP_TEST_BOX_CLIENT_ID: ${{ secrets.UPUP_TEST_BOX_CLIENT_ID }} + UPUP_TEST_DROPBOX_REFRESH_TOKEN: ${{ secrets.UPUP_TEST_DROPBOX_REFRESH_TOKEN }} + UPUP_TEST_GDRIVE_REFRESH_TOKEN: ${{ secrets.UPUP_TEST_GDRIVE_REFRESH_TOKEN }} + UPUP_TEST_ONEDRIVE_REFRESH_TOKEN: ${{ secrets.UPUP_TEST_ONEDRIVE_REFRESH_TOKEN }} + run: | + if [ -n "$UPUP_TEST_BOX_CLIENT_ID" ] || [ -n "$UPUP_TEST_DROPBOX_REFRESH_TOKEN" ] || [ -n "$UPUP_TEST_GDRIVE_REFRESH_TOKEN" ] || [ -n "$UPUP_TEST_ONEDRIVE_REFRESH_TOKEN" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice title=Drive sandbox skipped::No UPUP_TEST_* drive sandbox secrets are configured — the live cloud-drive integration suite did NOT run. drive-clients.ts stays covered by its mocked unit tests. Any provider left unconfigured skips individually inside the suite." + echo "## Drive sandbox: SKIPPED (no UPUP_TEST_* secrets)" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Setup pnpm + if: steps.creds.outputs.present == 'true' + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - name: Setup Node.js + if: steps.creds.outputs.present == 'true' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Install dependencies + if: steps.creds.outputs.present == 'true' + run: pnpm install --frozen-lockfile + + - name: Build @upupjs/core (the live suite imports drive-clients.ts → @upupjs/core) + if: steps.creds.outputs.present == 'true' + run: pnpm --filter @upupjs/core build + + - name: Rotate + write back the OneDrive refresh token (single mint point) + if: steps.creds.outputs.present == 'true' + env: + UPUP_TEST_ONEDRIVE_CLIENT_ID: ${{ secrets.UPUP_TEST_ONEDRIVE_CLIENT_ID }} + UPUP_TEST_ONEDRIVE_CLIENT_SECRET: ${{ secrets.UPUP_TEST_ONEDRIVE_CLIENT_SECRET }} + UPUP_TEST_ONEDRIVE_REFRESH_TOKEN: ${{ secrets.UPUP_TEST_ONEDRIVE_REFRESH_TOKEN }} + UPUP_TEST_ONEDRIVE_TENANT: ${{ secrets.UPUP_TEST_ONEDRIVE_TENANT }} + # Fine-grained PAT with repo Secrets:write — enables the rotated + # token to be persisted. Absent → OneDrive is skipped (the stored + # refresh token is left untouched); see the script's header. + GH_SECRETS_WRITE_PAT: ${{ secrets.GH_SECRETS_WRITE_PAT }} + run: node scripts/drive-sandbox/refresh-one-drive-token.mjs + + - name: Seed fixture files (idempotent) into every configured account + if: steps.creds.outputs.present == 'true' + env: + UPUP_TEST_BOX_CLIENT_ID: ${{ secrets.UPUP_TEST_BOX_CLIENT_ID }} + UPUP_TEST_BOX_CLIENT_SECRET: ${{ secrets.UPUP_TEST_BOX_CLIENT_SECRET }} + UPUP_TEST_BOX_ENTERPRISE_ID: ${{ secrets.UPUP_TEST_BOX_ENTERPRISE_ID }} + UPUP_TEST_DROPBOX_APP_KEY: ${{ secrets.UPUP_TEST_DROPBOX_APP_KEY }} + UPUP_TEST_DROPBOX_APP_SECRET: ${{ secrets.UPUP_TEST_DROPBOX_APP_SECRET }} + UPUP_TEST_DROPBOX_REFRESH_TOKEN: ${{ secrets.UPUP_TEST_DROPBOX_REFRESH_TOKEN }} + UPUP_TEST_GDRIVE_CLIENT_ID: ${{ secrets.UPUP_TEST_GDRIVE_CLIENT_ID }} + UPUP_TEST_GDRIVE_CLIENT_SECRET: ${{ secrets.UPUP_TEST_GDRIVE_CLIENT_SECRET }} + UPUP_TEST_GDRIVE_REFRESH_TOKEN: ${{ secrets.UPUP_TEST_GDRIVE_REFRESH_TOKEN }} + # OneDrive uses the access token the rotation step wrote to + # $GITHUB_ENV (UPUP_TEST_ONEDRIVE_ACCESS_TOKEN) — its refresh + # token is deliberately NOT exposed to seed/test steps. + run: node scripts/drive-sandbox/seed.mjs all + + - name: Run the live cloud-drive integration suite + if: steps.creds.outputs.present == 'true' + env: + UPUP_DRIVE_SANDBOX: '1' + UPUP_TEST_BOX_CLIENT_ID: ${{ secrets.UPUP_TEST_BOX_CLIENT_ID }} + UPUP_TEST_BOX_CLIENT_SECRET: ${{ secrets.UPUP_TEST_BOX_CLIENT_SECRET }} + UPUP_TEST_BOX_ENTERPRISE_ID: ${{ secrets.UPUP_TEST_BOX_ENTERPRISE_ID }} + UPUP_TEST_DROPBOX_APP_KEY: ${{ secrets.UPUP_TEST_DROPBOX_APP_KEY }} + UPUP_TEST_DROPBOX_APP_SECRET: ${{ secrets.UPUP_TEST_DROPBOX_APP_SECRET }} + UPUP_TEST_DROPBOX_REFRESH_TOKEN: ${{ secrets.UPUP_TEST_DROPBOX_REFRESH_TOKEN }} + UPUP_TEST_GDRIVE_CLIENT_ID: ${{ secrets.UPUP_TEST_GDRIVE_CLIENT_ID }} + UPUP_TEST_GDRIVE_CLIENT_SECRET: ${{ secrets.UPUP_TEST_GDRIVE_CLIENT_SECRET }} + UPUP_TEST_GDRIVE_REFRESH_TOKEN: ${{ secrets.UPUP_TEST_GDRIVE_REFRESH_TOKEN }} + run: pnpm --filter @upupjs/server exec vitest run tests/integration/drive-clients-live.integration.test.ts + + - name: Provision MinIO env (defaults; the HTTP-surface suite needs a live bucket) + if: steps.creds.outputs.present == 'true' + run: cp local-dev/.env.minio.example local-dev/.env.minio + + - name: Boot MinIO for the drive-sandbox HTTP-surface suite # ephemeral CI runner — no teardown + if: steps.creds.outputs.present == 'true' + run: | + pnpm run e2e:minio:up + for i in $(seq 1 45); do + if curl -sf http://localhost:9100/minio/health/live > /dev/null; then + echo "MinIO healthy on :9100"; exit 0 + fi + sleep 2 + done + echo "MinIO failed to become healthy on :9100" >&2 + exit 1 + + - name: Build @upupjs/server (the Playwright HTTP-surface suite imports its built dist) + if: steps.creds.outputs.present == 'true' + run: pnpm --filter @upupjs/server build + + - name: All-provider server-transfer through the real @upupjs/server HTTP surface (Playwright) + if: steps.creds.outputs.present == 'true' + env: + UPUP_DRIVE_SANDBOX: '1' + UPUP_TEST_BOX_CLIENT_ID: ${{ secrets.UPUP_TEST_BOX_CLIENT_ID }} + UPUP_TEST_BOX_CLIENT_SECRET: ${{ secrets.UPUP_TEST_BOX_CLIENT_SECRET }} + UPUP_TEST_BOX_ENTERPRISE_ID: ${{ secrets.UPUP_TEST_BOX_ENTERPRISE_ID }} + UPUP_TEST_DROPBOX_APP_KEY: ${{ secrets.UPUP_TEST_DROPBOX_APP_KEY }} + UPUP_TEST_DROPBOX_APP_SECRET: ${{ secrets.UPUP_TEST_DROPBOX_APP_SECRET }} + UPUP_TEST_DROPBOX_REFRESH_TOKEN: ${{ secrets.UPUP_TEST_DROPBOX_REFRESH_TOKEN }} + UPUP_TEST_GDRIVE_CLIENT_ID: ${{ secrets.UPUP_TEST_GDRIVE_CLIENT_ID }} + UPUP_TEST_GDRIVE_CLIENT_SECRET: ${{ secrets.UPUP_TEST_GDRIVE_CLIENT_SECRET }} + UPUP_TEST_GDRIVE_REFRESH_TOKEN: ${{ secrets.UPUP_TEST_GDRIVE_REFRESH_TOKEN }} + # OneDrive: UPUP_TEST_ONEDRIVE_ACCESS_TOKEN arrives via $GITHUB_ENV + # from the rotation step — nothing OneDrive-specific is set here. + MINIO_ROOT_USER: upupadmin + MINIO_ROOT_PASSWORD: upupadmin123 + UPUP_E2E_BUCKET: upup-e2e + UPUP_E2E_REGION: us-east-1 + UPUP_E2E_ENDPOINT: http://localhost:9100 + run: pnpm --filter @upupjs/e2e-test test:e2e:drive-sandbox + + # Rollup aggregator, mirroring Status Check / E2E Status Check. Not a + # branch-protection input (nightly has no PR) — it exists so one signal + # summarizes the night. Mastra-Evals is green-with-notice when the key is + # absent; that is an explicit documented skip, not a silent pass. + Nightly-Status: + name: Nightly Status Check + runs-on: ubuntu-latest + needs: + [ + E2E-Full, + Storybook-Builds, + Smoke-Packages, + Mastra-Evals, + Drive-Sandbox, + ] + if: always() + steps: + - name: Check status + run: | + if [ "${{ needs.E2E-Full.result }}" != "success" ] || \ + [ "${{ needs.Storybook-Builds.result }}" != "success" ] || \ + [ "${{ needs.Smoke-Packages.result }}" != "success" ] || \ + [ "${{ needs.Mastra-Evals.result }}" != "success" ] || \ + [ "${{ needs.Drive-Sandbox.result }}" != "success" ]; then + echo "One or more nightly jobs failed" + exit 1 + fi + echo "All nightly jobs passed" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 70249effe..64dff2852 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,77 +1,154 @@ name: Publish on: - push: - branches: - - master - - dev + push: + branches: + - master + - dev + +# Actions pinned to full commit SHAs (F-792); pnpm via pnpm/action-setup + +# setup-node cache:pnpm (F-787) — same mechanism as main.yml / e2e.yml. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true permissions: - contents: write - pull-requests: write - id-token: write + contents: write + pull-requests: write + id-token: write jobs: - publish: - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22.x - cache: pnpm - - - name: Upgrade npm for trusted publishing - run: npm install -g npm@11.5.1 - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Create Release Pull Request - if: github.ref == 'refs/heads/master' - id: changesets - uses: changesets/action@v1 - with: - createGithubReleases: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Check if current package version still needs publishing - if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' - id: package_meta - shell: bash - run: | - NAME=$(node -p "require('./packages/upup/package.json').name") - VERSION=$(node -p "require('./packages/upup/package.json').version") - echo "name=$NAME" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then - echo "needs_publish=false" >> "$GITHUB_OUTPUT" - else - echo "needs_publish=true" >> "$GITHUB_OUTPUT" - fi - - - name: Publish to npm - if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.needs_publish == 'true' - run: pnpm run release - - - name: Dry-run release on dev - if: github.ref == 'refs/heads/dev' - run: pnpm --filter upup-react-file-uploader run test-release - - - name: Create GitHub Release - if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.needs_publish == 'true' - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.package_meta.outputs.name }}@${{ steps.package_meta.outputs.version }} - name: ${{ steps.package_meta.outputs.name }}@${{ steps.package_meta.outputs.version }} - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.4.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + cache: pnpm + + - name: Upgrade npm for trusted publishing + run: npm install -g npm@11.16.0 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Create Release Pull Request + if: github.ref == 'refs/heads/master' + id: changesets + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 + with: + createGithubReleases: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check which package versions still need publishing + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' + id: package_meta + shell: bash + run: | + # F-781: derive the publishable set at runtime (the audit-prod.mjs + # predicate: @upupjs/* workspace packages that are not private) instead of + # a hand-maintained PKGS=(react server core ...) array that silently + # under-scopes the publish gate when a package is added/removed. + TO_PUBLISH="" + while IFS= read -r ref; do + npm view "$ref" version >/dev/null 2>&1 || TO_PUBLISH="$TO_PUBLISH $ref" + done < <(pnpm -r ls --depth -1 --json | jq -r '.[] | select(.name | startswith("@upupjs/")) | select(.private != true) | "\(.name)@\(.version)"') + TO_PUBLISH=$(echo $TO_PUBLISH | xargs) + echo "to_publish=$TO_PUBLISH" >> "$GITHUB_OUTPUT" + [ -n "$TO_PUBLISH" ] && echo "any_needs_publish=true" >> "$GITHUB_OUTPUT" || echo "any_needs_publish=false" >> "$GITHUB_OUTPUT" + + # ── Pre-publish gate (F-791) ────────────────────────────────────────── + # Publishing from master implies its PR passed main.yml/e2e.yml, but a + # direct push (or unset branch protection) would publish a commit those + # gates never ran on. So the fast static gates that main.yml runs on PRs + # are re-run here on the exact commit being published: prettier, lint, + # env:check, vocab:check, audit:prod (all seconds-fast) alongside the + # existing typecheck/test/build/size/smoke. e2e is deliberately NOT here: + # it needs a MinIO service + Playwright browsers (minutes) and gates PRs + # in e2e.yml; publishing from master trusts that PR gate. Branch + # protection must require both rollups (see CLAUDE.md CI section). + - name: Prettier check + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.any_needs_publish == 'true' + run: pnpm run prettier-check + + - name: Lint + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.any_needs_publish == 'true' + run: pnpm run lint + + - name: Env schema drift + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.any_needs_publish == 'true' + run: pnpm run env:check + + - name: Retired-vocabulary census + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.any_needs_publish == 'true' + run: pnpm run vocab:check + + - name: Prod dependency audit (publishable packages, fail on high+) + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.any_needs_publish == 'true' + run: pnpm run audit:prod + + - name: Typecheck (all packages) + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.any_needs_publish == 'true' + run: pnpm run typecheck + + - name: Unit suites (all packages) + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.any_needs_publish == 'true' + run: pnpm run test + + - name: Build (turbo) + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.any_needs_publish == 'true' + run: pnpm run build + + - name: Bundle-size budgets + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.any_needs_publish == 'true' + run: pnpm run size + + - name: Package smoke (real tarball consumer) + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.any_needs_publish == 'true' + run: pnpm run smoke:packages + + # Auth: all nine @upupjs packages now exist on npm (first published + # 2026-07-13), so OIDC trusted publishing is the intended path. Configure + # a trusted publisher per package on npmjs.com (repo DevinoSolutions/upup, + # workflow publish.yml); then with id-token:write (above) + Node >=22.14 + + # npm >=11.5.1, `pnpm publish` exchanges the GitHub OIDC token automatically + # and no registry secret is needed. NODE_AUTH_TOKEN (NPM_TOKEN secret) stays + # as a fallback until every package has its trusted publisher configured — + # once OIDC is verified in a real release, the secret and the env below can + # be dropped. OIDC requires Node >=22.14, so this publish-scoped setup-node + # pins 22.14 (the gate steps above ran on .nvmrc / Node 20). registry-url is + # scoped here so install/gate steps never see a token-referencing .npmrc. + - name: Set up Node 22 + npm registry for OIDC/token publish + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.any_needs_publish == 'true' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22.14.0' + registry-url: 'https://registry.npmjs.org' + + - name: Publish to npm + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.any_needs_publish == 'true' + run: pnpm run release + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Dry-run release on dev + if: github.ref == 'refs/heads/dev' + run: pnpm run test-release + + - name: Create GitHub Releases (per published package) + if: github.ref == 'refs/heads/master' && steps.changesets.outputs.hasChangesets == 'false' && steps.package_meta.outputs.any_needs_publish == 'true' + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TO_PUBLISH: ${{ steps.package_meta.outputs.to_publish }} + run: | + for ref in $TO_PUBLISH; do + case "$ref" in *[!A-Za-z0-9@._/-]*) echo "skip invalid ref: $ref"; continue;; esac + gh release create "$ref" --title "$ref" --generate-notes || true + done diff --git a/.gitignore b/.gitignore index baa1f9f05..024d724aa 100644 --- a/.gitignore +++ b/.gitignore @@ -8,12 +8,15 @@ packages/*/node_modules **/.turbo-cache **/.next **/build -packages/upup/dist +packages/*/dist packages/upup/dist-node apps/landing/public/documentation coverage playwright-report +test-results/ storybook-static +.tmp/ +graphify-out/ # Logs *.log @@ -23,15 +26,40 @@ storybook-static .env.* !.env.example !local-dev/.env.ports +!local-dev/.env.minio.example +!local-dev/.env.test.example # Editors .DS_Store .idea .vscode +# Claude Code +.claude/ + +# Storybook +apps/*/storybook-static + # Misc *.tgz # dist /dist /dist-node + +# Brainstorm companion sessions +.superpowers/ + +# Local AI-session workspaces — specs, plans, audit records. Deliberately +# untracked (see CLAUDE.md "Git & commits"); durable process truth is promoted +# into CLAUDE.md, everything else stays on this machine. +docs/superpowers/ +audit/ +tmp-*.txt + +# App build outputs (packages/*/dist is covered above) +apps/*/dist + +# Visual-layer captures (CI artifact -> future snapvisor.io input; no goldens +# are committed — snapvisor owns baselines server-side) +apps/e2e-test/screenshots/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 12de39be7..c52d7d279 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,5 @@ -pnpm --filter upup-react-file-uploader run prettier-check -pnpm --filter upup-react-file-uploader run test -pnpm --filter upup-react-file-uploader run build -pnpm --filter upup-react-file-uploader run build-storybook +#!/usr/bin/env sh +pnpm exec lint-staged +pnpm --filter @upupjs/core run test +pnpm --filter @upupjs/react run test +pnpm --filter @upupjs/server run test diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 000000000..aa916ad4c --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +pnpm run typecheck +pnpm exec turbo run lint --continue +pnpm run knip diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..768291372 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +# pnpm publish (invoked by `changeset publish`) must not block on branch or +# working-tree git checks — the release runs post-merge in CI on master. +# Restates the historical per-package `pnpm publish --no-git-checks`. +git-checks=false diff --git a/.nvmrc b/.nvmrc index 0254b1e63..ccc4c6c7f 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.18.2 +20.20.2 diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 000000000..66c91e8ad --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,73 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": { + "correctness": "error", + "suspicious": "warn", + "perf": "warn" + }, + "plugins": ["typescript", "import", "unicorn", "oxc"], + "rules": { + "no-console": ["error", { "allow": ["warn", "error"] }], + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "libheif-js", + "message": "Heavy dep: dynamic import() behind @upupjs/core/steps/heic only (principle 6)." + }, + { + "name": "tus-js-client", + "message": "Heavy dep: dynamic import() behind @upupjs/core/strategies/tus-upload only (principle 6)." + } + ] + } + ], + "no-unused-vars": "warn", + "no-unused-expressions": "warn", + "no-control-regex": "warn", + "no-constant-binary-expression": "warn", + "no-unsafe-optional-chaining": "warn", + "unicorn/no-useless-fallback-in-spread": "warn", + "unicorn/no-useless-spread": "warn", + "unicorn/prefer-string-starts-ends-with": "warn" + }, + "overrides": [ + { + "files": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/tests/**", + "**/__tests__/**", + "**/*.test.tsx", + "**/*.spec.tsx" + ], + "rules": { "no-console": "off" } + }, + { + "files": [ + "packages/core/src/steps/heic.ts", + "packages/core/src/strategies/tus-upload.ts", + "packages/core/src/**/worker*" + ], + "rules": { "no-restricted-imports": "off" } + }, + { + "files": ["apps/**", "**/scripts/**", "scripts/**"], + "rules": { "no-console": "off" } + } + ], + "ignorePatterns": [ + "**/dist/**", + "**/dist-node/**", + "**/.next/**", + "**/storybook-static/**", + "**/coverage/**", + "apps/e2e-test/dist/**", + "**/public/**", + "**/*.d.ts", + "audit/**", + "docs/**", + "**/*.md" + ] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..7b15b00fb --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +**/dist/** +**/.next/** +**/storybook-static/** +**/coverage/** +**/node_modules/** +pnpm-lock.yaml +**/*.snap +**/*.d.ts +docs/superpowers/** +audit/** +test-results/** +apps/e2e-test/dist/** diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..f39bfe019 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "tabWidth": 4, + "singleQuote": true, + "semi": false, + "arrowParens": "avoid", + "endOfLine": "auto" +} diff --git a/.size-limit.json b/.size-limit.json new file mode 100644 index 000000000..780ef1490 --- /dev/null +++ b/.size-limit.json @@ -0,0 +1,68 @@ +[ + { + "name": "@upupjs/core", + "path": "packages/core/dist/**/*.js", + "gzip": false, + "brotli": false, + "limit": "410 KB" + }, + { + "name": "@upupjs/react", + "path": "packages/react/dist/**/*.js", + "gzip": false, + "brotli": false, + "limit": "430 KB" + }, + { + "name": "@upupjs/vue", + "path": "packages/vue/dist/**/*.js", + "gzip": false, + "brotli": false, + "limit": "460 KB" + }, + { + "name": "@upupjs/svelte", + "path": "packages/svelte/dist/**/*.{js,svelte}", + "gzip": false, + "brotli": false, + "limit": "370 KB" + }, + { + "name": "@upupjs/vanilla", + "path": "packages/vanilla/dist/**/*.js", + "gzip": false, + "brotli": false, + "limit": "320 KB" + }, + { + "name": "@upupjs/angular", + "path": "packages/angular/dist/**/*.mjs", + "gzip": false, + "brotli": false, + "limit": "590 KB" + }, + { + "name": "@upupjs/preact", + "path": [ + "packages/preact/dist/**/*.js", + "!packages/preact/dist/filerobot-island.js" + ], + "gzip": false, + "brotli": false, + "limit": "670 KB" + }, + { + "name": "@upupjs/preact (image editor island)", + "path": "packages/preact/dist/filerobot-island.js", + "gzip": false, + "brotli": false, + "limit": "4 MB" + }, + { + "name": "@upupjs/next", + "path": "packages/next/dist/**/*.js", + "gzip": false, + "brotli": false, + "limit": "8 KB" + } +] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..98fb8067c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,241 @@ +# Changelog + +All notable changes to this project are documented here. Dates use `YYYY-MM-DD`. + +## Current architecture (2.2.0) + +upup ships as **nine published `@upupjs/*` packages**, versioned and released +together via [changesets](https://github.com/changesets/changesets): + +- **`@upupjs/core`** — the headless engine (file state, upload pipeline, + cloud-drive plugins, i18n, theme). Zero framework dependencies, and published + in its own right — not a private, bundled-in source. +- **`@upupjs/react`** — the canonical UI. `@upupjs/vue`, `@upupjs/svelte`, + `@upupjs/angular`, and `@upupjs/vanilla` are native, DOM-identical ports of it, and + `@upupjs/preact` is a `preact/compat` re-export of `@upupjs/react`. +- **`@upupjs/next`** — client re-export plus `/server` route handlers (App and + Pages routers). +- **`@upupjs/server`** — optional Server Mode endpoints (S3/MinIO presign + proxy + upload, drive-token exchange) behind an HMAC-signed upload-token trust model. + The framework-agnostic factory is `createUpupHandler` (`createUpupNextHandler` + wraps it for Next.js). The server-side drive→S3 transfer buffer is bounded at a + fixed 5 MB (`SINGLE_PUT_MAX_BYTES`). + +Client Mode (browser ↔ storage direct) is the default; Server Mode is strictly +opt-in. + +**Versioning and release notes are generated per package by changesets** — +consult each package's own `CHANGELOG.md` for its published history going +forward, and `.github/workflows/publish.yml` for how the nine packages are +released together. + +--- + +> **Historical log — pre-v2 / early-v2.** _(Editorial note added 2026-07-08.)_ +> The dated entries below predate the architecture described above and are kept +> verbatim as a historical record. They describe a superseded design whose names +> no longer exist in the codebase: a two-package publish +> (`upup-react-file-uploader` plus a private `@upupjs/shared`), a `createHandler()` +> server API, a configurable `multipartThreshold` server knob, and the `Adapter*` +> UI vocabulary (`sourceSelector.adapterButton`, the uploader's "adapter row") +> since swept to `Source*` / `Drive*` / `Uploader*` names. Read them as history, +> not as the current 2.2.0 API. + +--- + +## [Unreleased] — planned v2.2.0 + +Adds Server Mode end-to-end. Client Mode (v2.0 / v2.1) is unchanged +and remains the default — no migration required. + +### Added + +- **`mode` prop on ``.** Accepts `'client'` (default) or + `'server'`. In Server Mode the uploader talks only to the consumer's + `serverUrl`; drive APIs and storage writes are proxied through + `@upupjs/server`. +- **`@upupjs/server` Server Mode surface.** `createHandler()` now + implements the 3 TODO routes from v2.1: `/auth/:provider/cb` + (OAuth code exchange for Google, Microsoft, Dropbox, Box), + `/files/:provider` (list with search + folder navigation, normalised + `{ id, name, size, mimeType, isFolder, modifiedAt }`), + `/files/:provider/transfer` (streams drive → S3, switches to + multipart at `multipartThreshold` — default 100 MB). +- **`InMemoryTokenStore`** — TTL-aware reference token store. Three + string-in-string-out methods; swap for Redis / KV / DynamoDB in + production. +- **`getUserId` config hook** — resolves the authenticated user per + request for OAuth state scoping and token persistence. +- **`box` provider slot** in `createHandler` config. Server Mode lists + and transfers Box files through Box's Content API (`api.box.com/2.0`). +- **`ServerModeDriveUploader` React component** — uses our picker UI + for all 4 drives in Server Mode, handles re-auth popups via + `postMessage` back to the opener window. +- **Guides: `docs/guides/modes.md` + `docs/guides/server-mode-setup.md`.** + One-page setup for Next.js App Router + Redis-style token store. + +### Security + +- No OAuth refresh tokens are persisted by default. Access-token + expiry returns `401 { reauth: true }`; the client surfaces its + existing "Sign in" state. Shorter blast radius than long-lived + refresh tokens in an untrusted store. + +### Notes + +- Server Mode is strictly opt-in. `mode="client"` matches v2.0 / v2.1 + behaviour byte-for-byte. +- `@upupjs/server` remains Node-only and has no runtime entry in the + React package's dist — your client bundle stays free of AWS SDKs. + +--- + +## [Unreleased] — planned v2.1.0 + +This release removes four legacy v1 props and stitches up the last +rough edges from the v2 refactor. It is a **breaking change** for +anyone still on the v1 surface; see `docs/migration/v2-to-v2.1.md` for +the one-page migration guide. + +### ⚠️ Breaking changes + +- **Entire package rewire.** The legacy v1 implementation that lived + in `packages/upup` has been deleted; `upup-react-file-uploader@2.1` + is now backed by the v2 code in `packages/react` with + `@upupjs/core`/`@upupjs/shared` bundled in. Consumers upgrading from + v2.0.0 get a fundamentally different runtime — the public prop + surface is the migration guide (`docs/migration/v2-to-v2.1.md`) + but the engine, internals, i18n, theming, pipeline, and server + helpers are all new. +- **Removed `dark` prop** — use `theme.mode: 'light' | 'dark' | 'system'`. + The system path observes `prefers-color-scheme` and `html.dark` / + `[data-theme]` ancestors. +- **Removed `limit` prop** — use `maxFiles`. Same semantics, new name. +- **Removed `shouldCompress` prop** — use `imageCompression`. Same + semantics. +- **Removed `classNames` prop** — use `theme.slots` with the nested + shape (`fileList.uploadButton`, `sourceSelector.adapterButton`, etc.). + A new `flattenSlotsToClassNames()` helper in `@upupjs/shared` bridges + the nested override onto the internal flat shape the component tree + consumes. +- `theme.slots` is typed as `DeepPartialSlots` so partial overrides + compile. +- `UpupUploaderPropsClassNames` is no longer publicly exported from + `@upupjs/react`. +- `CoreOptions.locale` and `CoreOptions.translations` in `@upupjs/core` + changed from `unknown` to proper types (`LocaleBundle | +UpupLocaleCode` and `Partial` respectively). No + runtime change — consumers passing `unknown` now fail type-check. + +### Added + +- **Four new `provider` values**: `'r2'` (Cloudflare R2), `'wasabi'`, + `'minio'`, `'gcs'` (Google Cloud Storage via S3 interop). All route + through the same S3-compatible client path as `aws`/`backblaze`/ + `digitalocean`; your server-side presigner decides which SDK + + endpoint to use when you receive the string. `UpupProvider` enum + gains `CloudflareR2`, `Wasabi`, `MinIO`, `GCS` members. +- **`theme.slots` is now wired end-to-end.** Before this release it + was publicly typed but silently ignored; the runtime read from the + flat `classNames` prop. Now slot overrides reach the DOM. Four new + slots added to `UpupThemeSlots` to cover v1 `classNames` keys that + had no v2 equivalent: `filePreview.icon`, `fileList.addMoreButton`, + `driveBrowser.itemInner`, `driveBrowser.searchContainer`. +- **Nine brand icons exported** from `@upupjs/react` for reuse: + `MyDeviceIcon`, `BoxIcon`, `DropBoxIcon`, `GoogleDriveIcon`, + `OneDriveIcon`, `LinkIcon`, `CameraIcon`, `AudioIcon`, + `ScreenCastIcon`. These are the same SVGs used by the uploader's + adapter row. +- `onBeforeFileAdded` now honours its typed contract: returning a + `File` replaces the original file in the upload queue (was + previously dropped). +- `paused` translation key + all 9 locale packs updated. The status + label next to the progress bar is no longer hardcoded English. +- `authenticatePrompt` + `signInWith` translation keys (DriveAuthFallback + used to hardcode these). +- `data-upup-slot="drive-browser-header"` + `"drive-browser-item"` — + previously the only two components without a slot attribute. + +### Fixed + +- **SSR hydration mismatches** on first paint. Root causes: reading + `navigator.onLine` (undefined in Node 21+), reading + `html.classList.contains('dark')` during render, and calling + `window.matchMedia` in the initial useState. All moved behind a + mount guard or safe-initial-value pattern. +- Dropbox hooks imported `DropboxFile`, `DropboxRoot`, `DropboxUser` + from the `dropbox` npm package; none of those are actual exports. + Moved to a local `dropbox-types.ts` module. The bogus + `@ts-expect-error typings incomplete` in `useDropboxAuth` is gone. +- Dropbox API errors logged via `console.error` now also route + through the consumer-facing `onError` callback. + +### Interactive playground (`@upupjs/interactive-example`) + +The in-app playground got a full UX pass: + +- **Source tiles with authentic brand colours** in place of text-only + checkboxes. +- **Live event log panel** beneath the preview — toggle any callback + in the Events category and fired events stream in with timestamps + + JSON argument previews. Auto-scrolls, caps at 200 rows, with a + Clear button. +- **Quick-start presets row** at the top of the sidebar: Photos only, + Big uploads, Cloud storage, Edit before upload, Dark mode, Clear. + Each applies a lean config snapshot via `ConfigContext.setConfig`. +- **Segmented enum controls** auto-selected for any enum with ≤ 6 + options; long lists (locales) stay as `` + hex readout) for + `theme.tokens.color.primary`. +- **Range slider** for `imageEditor.output.quality`. +- **Inline descriptions** on every toggle, enum, and number input — + no more guessing what "Compress images" or "Mini mode" actually does. +- Cloud drive credential blocks stamped with brand icons in their + legends. +- Renamed `isProcessing (demo loading state)` → "Demo: show loading + state" with a clearer description. Removed duplicated + "Auto-configure S3 CORS" toggle from Behavior (kept in Advanced). + +### Tooling + +- `pnpm dev` now starts the playground alongside landing + docs. +- Playground dev server self-heals stale Next.js `.next/dev/lock` + files on Windows. +- Seed v2-clean branch comparison doc at + `docs/v2-clean-vs-dev.md`. + +### Test coverage + +- +12 unit tests for `flattenSlotsToClassNames` in `@upupjs/shared` +- +3 DOM integration tests in `@upupjs/react` proving `theme.slots` + overrides reach the rendered markup +- +3 new `EnumSelect` cases covering segmented default + auto-select + fallback layouts +- All 1,774 tests green across shared / core / react / interactive-example + +Totals by package (after this release): + +- shared: 360 tests +- core: 571 tests +- react: 780 tests +- interactive-example: 66 tests + +--- + +## [2.0.0] — 2026-03-16 + +Initial v2 publish of `upup-react-file-uploader`. The monorepo +refactor (86-feature checklist in +`docs/superpowers/audits/V2_COMPLETE_STATUS.md`) landed here. Ship +history prior to that tag predates this changelog. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..ce8fface0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,578 @@ +# CLAUDE.md + +Operating guide for AI agents (and humans) working in this repo. It records what +the code cannot tell you: how work gets verified, which conventions are +deliberate, and where the traps are. Treat it as authoritative and update it in +the same commit as any process change it describes. + +## What this is + +upup — an MIT-licensed file uploader: one headless core plus native UI packages +for every major framework, with optional server-mode uploads (client → your +server → S3-compatible storage) and cloud-drive sources (Google Drive, OneDrive, +Dropbox, Box), camera, screen capture, and link imports. pnpm workspace + turbo; +publishable packages are `@upupjs/*`, released together via changesets. + +## Package map + +Publishable (`packages/`): + +- `@upupjs/core` — headless engine: file state + orchestrator, upload pipeline + (compression, HEIC, web-worker offload), cloud-drive plugins, UI controllers, + i18n, theme. **Zero framework dependencies — keep it that way.** The public + `.` entry is a curated allow-list — every export is named explicitly, no + `export *` — currently 53 values / 74 types (a snapshot, not a ceiling to + preserve). Implementation details (`FileManager`, `UploadManager`, + `PipelineEngine`, the orchestrator, controllers, context shapes, the + multipart-session store, low-level utils) live behind `@upupjs/core/internal`, + a deep-import-only subpath alongside the existing `./contracts`/`./i18n`/ + `./theme`/`./strategies` pattern — if you need one of them outside core's own + `src/`, import it from `./internal`, never re-add it to the public entry. + Adding a NEW core subpath needs two extras: a `typesVersions` fallback + (ng-packagr cannot resolve `exports` conditional subpaths — angular's + library build throws TS2307 without it), and a + `packages/core/vitest.config.ts` alias entry placed BEFORE the bare + `@upupjs/core` key (Vite matches aliases in object order; the bare key + prefix-shadows subpaths — do not alphabetize). + Every one of the nine `@upupjs/*` packages carries a `public-api.test.ts` + (or `.spec.ts`, matching that package's own vitest convention) pinning its + exact runtime export list, plus core alone also pins `./internal`'s list + (`tests/internal-surface.test.ts`) — a name silently added or removed from + either surface is a real API change the pin will catch; update the checked-in + list deliberately, don't loosen the assertion to make it pass. +- `@upupjs/react` — the canonical UI. Every other framework matches its DOM. +- `@upupjs/vue`, `@upupjs/svelte`, `@upupjs/angular`, `@upupjs/vanilla` — native ports + of the React UI (same DOM contract, same Tailwind classes). +- `@upupjs/preact` — compat re-export of `@upupjs/react` via `preact/compat`; the + image editor lazily loads real React as an isolated island. +- `@upupjs/next` — client re-export plus `/server` route handlers (App and Pages + routers). +- `@upupjs/server` — server-mode endpoints: S3/MinIO presign + proxy upload, + drive-token exchange, HMAC-signed trust model (signed length, key/uploadId + binding, required secrets). Node<->Web request/response bridging for the + Node adapters (express/fastify/`@upupjs/next`'s pages-handler) lives in ONE + place — `@upupjs/server/node-bridge` (`toWebRequest`/`writeWebResponse`); a + future custom Node adapter imports it rather than re-hand-rolling the + conversion. `createUpupHandler` requires `storage.type` to be an S3 / + S3-compatible provider — it throws at construct time for a value with no + S3 surface (`NON_S3_STORAGE_PROVIDERS` in `@upupjs/core`, currently just + `azure`); `hono.ts`/`next.ts` (App Router) are web-native and don't need + the bridge. `handler.ts` is decomposed by concern (the deferred N4 server + decomposition, now done — P15): `respond.ts` is the single CORS-safe response + home — a per-request `Responder` closes over the CORS headers + an + `x-upup-request-id`, every route returns through `res.json`/`html`/`redirect`/ + `noContent`/`fail`, and **a route composing its own `Response` (any + `new Response(` outside `respond.ts`) is now a defect**; `upload-routes.ts` is + the HMAC upload trust boundary in isolation (metadata validation + presign + + multipart lifecycle + token issue/verify/owner-bind/size-envelope); + `oauth.ts` is the drive OAuth flow + provider identity; `drive-routes.ts` is + the authenticated drive list/transfer HTTP routes; `drive-clients.ts` is the + per-provider drive API calls behind the `DRIVE_CLIENTS` registry + (`getDriveClient(provider)` — adding a provider is one client-fn pair + one + row, no dual switch). Residual `handler.ts` is the thin router: + secret/identity/`storage.type` construct guards + route dispatch building one + `Responder`. `health.ts` routes through that `Responder` too (F-715) — the + contract has zero exceptions and is pinned by + `packages/server/tests/response-contract.test.ts`, so health responses carry + `x-upup-request-id` like every other route. + +Private (`packages/`): `interactive-example`, `storybook-config`, +`tailwind-config` (shared Tailwind/postcss factory — theme edits happen in one +file, not per-package), `eslint-config` (shared flat-config factory mirroring +`tailwind-config`; phase 1 lints TS/JS only — `.vue`/`.svelte` SFC and Angular +HTML templates are deferred to phase 2; `@upupjs/react`/`@upupjs/preact` +additionally compose its `reactHooksConfig` named export — kept out of the +shared default because vue/svelte composables are also named `use*` and would +false-positive against `react-hooks/rules-of-hooks`). + +Apps (`apps/`): `playground` (main dev app), `landing`, `docs`, `e2e-test` +(Playwright: deep React suite + cross-framework parity), `storybook-react/vue/ +svelte/vanilla/angular/preact` (per-framework style-parity references), +`next-example`, `mastra` (agents/tools for the interactive playground). + +## Non-negotiable principles + +1. **React is the visual canon.** UI changes land in `@upupjs/react` first; the + other frameworks are then made DOM-identical (the parity harness enforces + this byte-for-byte). +2. **No smoke-test theater.** "It renders" is not verification. Features are + proven by the real e2e gate (real MinIO, real uploads) or a live check in + the playground/storybooks. +3. **Copy-then-modify, never mass-migrate.** Port components one at a time + against a working reference; screenshot-driven for visual work. +4. **Duplication over indirection where it aids maintainers.** Per-framework + hooks/components are intentionally parallel. Do not DRY them into shared + abstractions that make single-framework edits harder to reason about. +5. **Rebuild before dependents see your edit.** Packages consume each other's + `dist/`, not `src/`. After editing `packages/core/src`, run + `pnpm --filter @upupjs/core build` (same idea for any `packages/*` edit) + unless the `pnpm run dev:package` watchers are running. Trap: `@upupjs/preact` + tests exercise its BUILT bundle (which inlines react), so after react/core + edits rebuild react + preact before trusting preact test results — a stale + dist fails with errors whose sourcemaps point at up-to-date `src/` files. +6. **Keep core's mandatory path lean.** Heavy capabilities are opt-in: + `libheif-js` (HEIC) and `tus-js-client` (resumable) are + `optionalDependencies` loaded via dynamic `import()` behind subpath exports + (`@upupjs/core/steps/heic`, `@upupjs/core/strategies/tus-upload`), and the + pipeline worker is a separate module, not inlined. Never add a static + top-level import of a heavy dependency to core's main entry. + `.size-limit.json` holds the per-package budgets; `pnpm run size` enforces. +7. **Never weaken the server trust model.** Server-mode requests are + HMAC-verified (signed length, key/uploadId binding, mandatory secrets) and + forged or unsigned requests must keep returning 403. + `packages/server/tests/handler-extended.test.ts` and + `tests/integration/trust-model.integration.test.ts` assert this — a handler + change that only passes by loosening a check is the wrong change. + `@upupjs/server` upload routes (`/presign`, `/multipart/init`) are + secure-by-default: they 403 `AUTH_REQUIRED` unless `auth`, `getUserId`, or + the explicit opt-in `allowAnonymousUploads:true` is configured. The upload + token's `uid` is enforced on the multipart continuation routes + (sign-part/complete/abort) whenever `getUserId` is set — a mismatch is 403 + `AUTH_DENIED` — and skipped (token possession is the model) otherwise. The + server→S3 drive-transfer buffer is bounded at a fixed 5 MB + (`SINGLE_PUT_MAX_BYTES`); the configurable `multipartThreshold` knob is + removed so memory safety cannot be raised away. + +## Gates — run before calling work done + +```bash +pnpm install # Node 20.20.2 (.nvmrc), pnpm 10.11.0 (packageManager) +pnpm run typecheck # turbo, all packages +pnpm run test # turbo, all unit suites +pnpm run build # turbo, all packages +pnpm run e2e # the REAL gate — see next section +pnpm run prettier-check # CI blocks on this (all 9 publishable packages' src, .ts/.tsx ONLY — .vue/.svelte SFC + .css are NOT covered; one root config) +pnpm run size # size-limit bundle budgets +pnpm run audit:prod # high+ advisories in the publishable prod trees +pnpm run lint # eslint flat-config: 9 @upupjs/* packages + 3 apps (playground, landing, docs); each leaf is `eslint . --max-warnings 0` so warnings gate (F-784) +pnpm run lint:ox # oxlint fast first-line (built-ins only, seconds) +pnpm run knip # dead-code / unused-dep detection (workspace-aware) +pnpm run env:check # .env.minio.example ↔ validate-env schema drift guard +pnpm run vocab:check # retired-vocabulary census: a naming sweep must land in + # EVERY layer (identifiers, DOM strings, templates, + # fixtures) — fails on any surviving retired token; + # exceptions are self-liquidating (a stale entry fails) +pnpm run test:quality # test-suite hygiene guard: committed .only, disabled + # tests without skip-allow(owner/reason/until) markers, + # tautological asserts, vague test names/filenames, + # unjustified Playwright sleeps (sleep-allow), mocks in + # integration/e2e layers (boundary-mock), regen-guard + # presence, continue-on-error in workflows. Exceptions + # list ships EMPTY and is inverse-forced; the ONE named + # carve-out is the guard's own self-test (its fixture + # corpus is deliberate rot specimens), pinned exactly. + # Discovery is `git ls-files` — an UNTRACKED new test + # file is invisible until staged, so verify after + # `git add`, not before. +pnpm run test:scripts # node:test self-tests for scripts/ci (the guard + the + # affected-test resolver's impact map) + scripts/lib +pnpm run smoke:packages # real npm-tarball consumer (packs all 9, isolated vite + # build, dist-shape + entry-budget asserts). Slow (~5m); + # run it after anything that can grow core/react's + # mandatory path — it sat red for weeks (487>450 KiB + # after P12/P19) because it only ran in never-executed CI +``` + +Baseline: every unit suite is green (16 packages, all 16 turbo `test` tasks +pass). There is no accepted red baseline — a failing spec is a real signal, +never noise. Both `test` and `typecheck` run with `--continue` (one package's +failure can't silently cancel or mask the others) and the `test` task depends +on `^build`, so downstream suites always run against freshly-built sibling +`dist/`, not stale output. + +Type errors are caught ONLY by the typecheck gate: package builds +(tsup/esbuild) and vitest are transpile-only, and the pre-commit hook runs +unit suites, not `tsc` — a change with a hard interface error can land on a +green hook and a green build (it has, twice: P16 and F-715). Run +`pnpm run typecheck` before trusting any type-touching change. + +Since 2026-07-07 the typecheck gate also covers the TEST trees: each +package's `typecheck` script runs its `tsconfig.test.json` after the base +config (angular reuses its pre-existing `tsconfig.spec.json`; preact/next +co-locate specs under `src` and were already covered). This closed the +grading audit's consensus #1 — the type-level halves of the public-API pins +(`expectTypeOf`, `@ts-expect-error` negatives) used to be dead in every gate. +Its first execution surfaced real drift the runtime suites tolerated (specs +importing types `@upupjs/core/internal` never exported, tests pinning the +retired `enableWorkers`/`appKey` option names, a dead `"link"` source id) — +treat a red test-tree typecheck as an API-drift signal, not test noise. + +`prettier-check`/`prettier-write` cover all 9 publishable packages' `src` — +but **`.ts`/`.tsx` ONLY** (P22, 2026-07-04): one root `.prettierrc.json` + +`.prettierignore`, byte-identical across packages (`packages/react/.prettierrc.json` +is gone — promoted to root). NOT repo-wide: the 78 `.vue`/`.svelte` SFCs under +`packages/{vue,svelte}/src` and all `.css` are deliberately ungated — no SFC +formatter parser (prettier-plugin-svelte / vue) is wired, an F-782 phase-2 +deferral mirroring lint's SFC gap. The SFCs are the canonical DOM ports, so +style drift in them is invisible to CI; check it by hand until the parser lands. +Run either through `rtk proxy`: the rtk filter has reported "all files +formatted" on a red `--check` (see Machine-local notes). + +Flake protocol: if a test fails only in the full run, re-run it isolated +before suspecting your change. Known load-sensitive cases: +`@upupjs/server tests/token-refresh.test.ts` ("refresh success") and +`tests/transfer.test.ts` (the 4 MB single-PUT case) can exceed their 5 s +timeouts when the whole suite runs but pass alone; six-storybook cf boots +can throw transient Windows `STATUS_STACK_BUFFER_OVERRUN`s. + +Dev loops: `pnpm run dev` (playground + landing + docs + package watchers), +`pnpm run dev:playground` for the quick loop, `pnpm run dev:storybook` for the +framework storybooks. + +## Verification discipline + +Habits that repeatedly separated real green from coincidental green during +the 2026-07 foundational audit; they apply to any non-trivial change: + +- Census before change: grep the BROAD token (`\bsetup\b`, not `setup:`) and + filter by hand — removing an interface member breaks object literals and + shorthand props, not just classes. Sweep data-driven tables (event-name + arrays, forwarding maps), not only static call sites. Include `tests/` + explicitly: base package tsconfigs `include: ["src"]`, so a src-only grep + misses them (the typecheck gate now runs `tsconfig.test.json` per package, + so drift turns red there — but a census still has to LOOK at tests to + understand what a change breaks before the gate tells you). +- "Passes after the change" ≠ "correct after the change" — verify the + mechanism (that the intended code path ran), not just the color. +- Plan claims decay: re-verify every file/inventory precondition at + execution HEAD, using the same execution-grade counter the change will use + (an eyeballed export inventory undercounted a real one 3x). +- Prefer structural acceptance criteria ("zero `new Response(` outside + respond.ts") over numeric budgets — counts re-base as neighbors change, + structure doesn't. Criteria are literal: a claim that silently narrows + scope ("lint passes" — but only packages) ships a red CI job. A new + package must also land its registration surfaces (pnpm-workspace globs, + barrels) or import probes fail. +- Cloning platform objects needs a mechanism, not a spread: `{...file}` + strips `File`'s blob slots — use `new File([prev], …)` + `Object.assign`. +- Re-pin, don't delete: when a refactor kills a test's mechanism, re-express + its intent through the supported API (a re-pinned test surfaced F-716; a + deleted one would have buried it). The pre-commit hook blocks red-test + commits — prove RED with direct vitest output quoted in the commit/PR + body, then fold test+fix into one green commit. Never `--no-verify`. +- The first cast of a widened net (new parity capture, broader lint scope) + should EXPECT pre-existing rot: pre-authorize a self-liquidating + exceptions list (the KNOWN_DIVERGENCES + inverse-forcing-check pattern) + instead of blocking the widening on fixing everything it reveals. + +## E2E — the real verification + +```bash +pnpm run e2e:minio:up # MinIO via repo-root docker compose + local-dev/.env.minio +pnpm run e2e # turbo build + deep React suite + cross-framework suite +pnpm run e2e:minio:down # NOTE: uses -v — wipes the bucket volume +``` + +- MinIO listens on :9100 (S3) / :9101 (console) per `local-dev/.env.minio` + (copy `local-dev/.env.minio.example` for OAuth-free defaults). **Never touch + :9000** — a MinIO container from another project may be running there. +- Docker error "all predefined address pools have been fully subnetted" → + `docker network prune -f`, then retry. +- The cross-framework webServer boots six storybooks; its start timeout is + 900 s (raised from 420 s after a cold 2-core CI runner blew the budget and + a loaded dev box blew 660 s, 2026-07-13). First boot is legitimately slow — + don't kill it. +- Cloud-drive OAuth (Google/OneDrive/Dropbox/Box) is interactive: a human + performs the login during live checks. Never automate or type credentials. +- Secrets live in `local-dev/.env.minio` and `.env.local` files — never commit + or print their values. +- Ad-hoc e2e/integration invocations must reproduce the root scripts' dotenv + wrapper (`dotenv -e local-dev/.env.minio -- `) — never hand-source the + env file. An unwrapped run starves the harness of creds and, before F-707 + moved the fallbacks to :9100, silently reached a foreign MinIO on :9000. + +### Cross-framework parity harness + +`apps/e2e-test/cross-framework/` renders the same story in all six frameworks +(Playwright projects `react`, `vue`, `svelte`, `vanilla`, `angular`, `preact`) +and compares normalized DOM + a11y against `parity-fixtures.json`. React is the +source of truth. After an intentional UI change: + +1. Set `UPDATE_PARITY=1` and run the parity spec with `--project react` + (`pnpm --filter @upupjs/e2e-test test:e2e:cf -- --project react`) — fixtures are + rewritten from React's DOM. +2. Review the `parity-fixtures.json` diff like code. +3. Unset the env var and run the full cross-framework suite — all six must pass. + +DOM contract strings (`data-testid="upup-*"`, `data-upup-slot`, `upup-*` CSS +hooks) are shared across all frameworks and asserted by tests. Renaming one is +a cross-framework breaking change: grep every package plus the fixtures before +touching them. + +### Visual screenshots (snapvisor.io) + +The e2e suites also freeze named product states as PNGs — +`apps/e2e-test/visual/product-state-screenshots.ts` is the one capture path +(element shot of the uploader root, CSS-pixel scale, animations/caret/fonts +settled, live regions masked). Naming contract +`screenshots///--.png` is what snapvisor.io +(our Argos-style visual-diff service) will key on once its uploader lands in +CI; until then the PNGs upload as workflow artifacts +(`e2e-visual-screenshots` / `nightly-visual-screenshots`, always — not just on +failure). No golden images are committed and there is no regen mode: baselines +live server-side in snapvisor, so there is nothing here for the regen guards +to protect. Every capture sits AFTER a real behavioral assertion — the +assertion proves the state happened, the pixels prove what it looked like. +Deep-dive: `docs/testing.md` "Visual screenshots". + +### What the harness cannot catch + +The harness compares normalized DOM structure, not rendered geometry. Three +recurring visual traps it will never flag — check these live (the screenshot +layer turns some of this into reviewable image diffs, but only for the states +the suites actually drive): + +- The uploader panel is a fixed-height container by design; unbounded media + clips. Every media element (camera, screen capture, previews) needs + `min-h-0 flex-1 object-contain` — see `CameraUploader` / + `ScreenCaptureUploader` in each framework for the reference pattern. +- Live previews: bind `srcObject` only after the conditional `