diff --git a/.env.e2e.example b/.env.e2e.example deleted file mode 100644 index 013174c2..00000000 --- a/.env.e2e.example +++ /dev/null @@ -1,41 +0,0 @@ -# E2E Testing Configuration - -# Required for LLM/vision tests (set in GitHub Actions secrets) -OPENROUTER_API_KEY=sk-or-v1-your-key-here -MODEL_NAME=google/gemini-3-flash-preview -VISION_MODEL_NAME=google/gemini-3-flash-preview -SKETCHI_APP_NAME=sketchi -SKETCHI_APP_COMPONENT=backend -AI_GATEWAY_API_KEY=vck_your-key-here - -# Optional (only needed for Browserbase rendering + Stagehand BROWSERBASE env) -BROWSERBASE_API_KEY=bb_live_your-key-here -BROWSERBASE_PROJECT_ID=your-project-id-here - -# Vercel preview deployment bypass (for API tests against preview URLs) -VERCEL_AUTOMATION_BYPASS_SECRET=your-bypass-secret-here - -# Target URL (defaults to http://localhost:3001) -STAGEHAND_TARGET_URL=http://localhost:3001 - -# Environment: LOCAL or BROWSERBASE (defaults to BROWSERBASE) -STAGEHAND_ENV=LOCAL - -# Optional authenticated local WorkOS test user -SKETCHI_E2E_EMAIL=your-workos-test-user@maildrop.cc -SKETCHI_E2E_PASSWORD=your-workos-test-password - -# Browser settings -STAGEHAND_HEADLESS=false -STAGEHAND_CHROME_PATH= - -# Screenshots and artifacts -STAGEHAND_SCREENSHOTS=true -STAGEHAND_SCREENSHOTS_DIR=tests/e2e/artifacts -STAGEHAND_CACHE_DIR=tests/e2e/artifacts/cache - -# Verbosity: 0, 1, or 2 -STAGEHAND_VERBOSE=0 - -# Strict mode: fail on visual warnings -STAGEHAND_VISUAL_STRICT=false diff --git a/.github/workflows/cd-release.yml b/.github/workflows/cd-release.yml deleted file mode 100644 index a8a58968..00000000 --- a/.github/workflows/cd-release.yml +++ /dev/null @@ -1,266 +0,0 @@ -# CD Release - Version bump + GitHub release creation -# Trigger: main push (skips version bump commits) -# Parallel: runs alongside ci-tests on main -name: cd-release - -on: - push: - branches: - - main - -permissions: - contents: write - pull-requests: write - -concurrency: - group: cd-release-${{ github.ref }} - cancel-in-progress: true - -jobs: - release: - runs-on: ubuntu-latest - # Skip if commit message contains [skip-release] or is a version bump commit - if: | - !contains(github.event.head_commit.message, '[skip-release]') && - !contains(github.event.head_commit.message, 'chore: bump version') - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 - - - name: Skip bump merges - id: skip_release - run: | - MSG=$(git log -1 --pretty=%B) - if echo "$MSG" | grep -q "chore: bump version"; then - echo "skip=true" >> $GITHUB_OUTPUT - echo "Skipping release for bump commit." - exit 0 - fi - - if echo "$MSG" | grep -q "chore/bump-version"; then - echo "skip=true" >> $GITHUB_OUTPUT - echo "Skipping release for bump merge." - exit 0 - fi - - PARENTS=$(git show -s --pretty=%P HEAD) - PARENT_COUNT=$(echo "$PARENTS" | wc -w | tr -d ' ') - if [ "$PARENT_COUNT" -gt 1 ]; then - MERGED_HEAD=$(echo "$PARENTS" | awk '{print $2}') - MERGED_MSG=$(git show -s --pretty=%B "$MERGED_HEAD") - if echo "$MERGED_MSG" | grep -q "chore: bump version"; then - echo "skip=true" >> $GITHUB_OUTPUT - echo "Skipping release for bump merge." - exit 0 - fi - fi - - # Skip releases when changes are limited to non-runtime paths (docs, OpenCode, CI, or the npm plugin package). - # This prevents bumping the app version when only publishing/iterating on the OpenCode plugin. - BEFORE_SHA="${{ github.event.before }}" - AFTER_SHA="${{ github.sha }}" - CHANGED_FILES="$(git diff --name-only "${BEFORE_SHA}..${AFTER_SHA}" || true)" - if [ -n "$CHANGED_FILES" ]; then - ONLY_IGNORED="true" - while IFS= read -r file; do - [ -z "$file" ] && continue - case "$file" in - README.md|AGENTS.md|bun.lock) ;; - docs/*|.codex/*) ;; - packages/opencode-excalidraw/*) ;; - .github/workflows/*) ;; - *) - ONLY_IGNORED="false" - break - ;; - esac - done <<< "$CHANGED_FILES" - - if [ "$ONLY_IGNORED" = "true" ]; then - echo "skip=true" >> $GITHUB_OUTPUT - echo "Skipping release: only non-runtime paths changed." - exit 0 - fi - fi - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - if: steps.skip_release.outputs.skip != 'true' - with: - bun-version: latest - - - name: Install dependencies - if: steps.skip_release.outputs.skip != 'true' - run: bun install - - - name: Configure Git - if: steps.skip_release.outputs.skip != 'true' - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - - - name: Get current version - id: current_version - if: steps.skip_release.outputs.skip != 'true' - run: echo "version=$(bun -e "console.log(require('./package.json').version)")" >> $GITHUB_OUTPUT - - - name: Create bump branch - id: bump_branch - if: steps.skip_release.outputs.skip != 'true' - run: | - BRANCH="chore/bump-version-${{ github.run_id }}-${{ github.run_attempt }}" - git checkout -b "$BRANCH" - echo "branch=$BRANCH" >> $GITHUB_OUTPUT - - - name: Bump version - id: bump_version - if: steps.skip_release.outputs.skip != 'true' - run: | - CUR_VERSION="${{ steps.current_version.outputs.version }}" - CUR_VERSION="${CUR_VERSION%%-*}" - IFS='.' read -r CUR_MAJOR CUR_MINOR _ <<< "$CUR_VERSION" - - for _ in {1..10}; do - npm version patch --no-git-tag-version - NEW_VERSION=$(bun -e "console.log(require('./package.json').version)") - NEW_VERSION_BASE="${NEW_VERSION%%-*}" - IFS='.' read -r NEW_MAJOR NEW_MINOR _ <<< "$NEW_VERSION_BASE" - if [ "$CUR_MAJOR" != "$NEW_MAJOR" ] || [ "$CUR_MINOR" != "$NEW_MINOR" ]; then - echo "Refusing to bump major/minor; patch-only enforced." - exit 1 - fi - - TAG="v$NEW_VERSION" - if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then - echo "Tag $TAG already exists; bumping again." - continue - fi - - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "Bumped version from ${{ steps.current_version.outputs.version }} to $NEW_VERSION" - exit 0 - done - - echo "Failed to find unused patch version after 10 bumps." - exit 1 - - - name: Commit version bump - if: steps.skip_release.outputs.skip != 'true' - run: | - git add package.json - git commit -m "chore: bump version to ${{ steps.bump_version.outputs.new_version }} [skip-release] [skip ci]" - git push --set-upstream origin "${{ steps.bump_branch.outputs.branch }}" - - - name: Create PR - id: bump_pr - if: steps.skip_release.outputs.skip != 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - PR_URL=$(gh pr create \ - --repo "$GITHUB_REPOSITORY" \ - --base main \ - --head "${{ steps.bump_branch.outputs.branch }}" \ - --title "chore: bump version to ${{ steps.bump_version.outputs.new_version }}" \ - --body "Automated patch version bump.") - echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT - - - name: Enable auto-merge - if: steps.skip_release.outputs.skip != 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - PR_URL="${{ steps.bump_pr.outputs.pr_url }}" - - set +e - MERGE_OUTPUT=$( - gh pr merge "$PR_URL" \ - --repo "$GITHUB_REPOSITORY" \ - --squash \ - --auto 2>&1 - ) - MERGE_STATUS=$? - set -e - - if [ "$MERGE_STATUS" -eq 0 ]; then - echo "$MERGE_OUTPUT" - exit 0 - fi - - if printf '%s' "$MERGE_OUTPUT" | grep -Eq "in clean status|Auto merge is not allowed"; then - echo "Auto-merge not applicable, merging immediately." - gh pr merge "$PR_URL" \ - --repo "$GITHUB_REPOSITORY" \ - --squash \ - --delete-branch - exit 0 - fi - - echo "$MERGE_OUTPUT" - exit "$MERGE_STATUS" - - - name: Wait for PR merge - if: steps.skip_release.outputs.skip != 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - PR_URL="${{ steps.bump_pr.outputs.pr_url }}" - for _ in {1..30}; do - MERGED_AT=$(gh pr view "$PR_URL" --repo "$GITHUB_REPOSITORY" --json mergedAt -q .mergedAt) - if [ -n "$MERGED_AT" ] && [ "$MERGED_AT" != "null" ]; then - echo "PR merged at $MERGED_AT" - git fetch origin main - git checkout main - git reset --hard origin/main - exit 0 - fi - sleep 5 - done - echo "PR did not merge in time." - exit 1 - - - name: Generate release notes - id: release_notes - if: steps.skip_release.outputs.skip != 'true' - run: | - # Get the latest tag (if any) - LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - - # Generate release notes - if [ -z "$LATEST_TAG" ]; then - NOTES="## Initial Release" - echo "Initial release - no previous tags found" - else - echo "Generating notes from $LATEST_TAG to HEAD" - # Get commit messages since last tag, excluding version bump commits - COMMITS=$(git log $LATEST_TAG..HEAD --pretty=format:"- %s" --no-merges | grep -v "chore: bump version" | grep -v "\[skip-release\]") - - if [ -z "$COMMITS" ]; then - NOTES="## Release v${{ steps.bump_version.outputs.new_version }}" - else - printf -v NOTES "## Release v${{ steps.bump_version.outputs.new_version }}\n\n### Changes\n%s" "$COMMITS" - fi - fi - - # Save to file for multiline support - echo "$NOTES" > release_notes.md - echo "Release notes generated" - - - name: Create GitHub Release - if: steps.skip_release.outputs.skip != 'true' - run: | - gh release create v${{ steps.bump_version.outputs.new_version }} \ - --title "Release v${{ steps.bump_version.outputs.new_version }}" \ - --notes-file release_notes.md \ - --target main - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Clean up - if: always() && steps.skip_release.outputs.skip != 'true' - run: | - rm -f release_notes.md diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml deleted file mode 100644 index b67601ac..00000000 --- a/.github/workflows/ci-tests.yml +++ /dev/null @@ -1,99 +0,0 @@ -# CI Tests - Convex backend unit tests -# Trigger: PR, main push -# Parallel: runs alongside cd-release on main -name: ci-tests - -on: - pull_request: - paths-ignore: - - "docs/**" - - "README.md" - - "AGENTS.md" - - ".codex/**" - - "packages/opencode-excalidraw/**" - - ".github/workflows/opencode-excalidraw-*.yml" - push: - branches: - - main - paths-ignore: - - "docs/**" - - "README.md" - - "AGENTS.md" - - ".codex/**" - - "packages/opencode-excalidraw/**" - - ".github/workflows/opencode-excalidraw-*.yml" - -jobs: - convex-tests: - if: | - (github.event_name != 'pull_request' || ( - github.event.pull_request.head.repo.full_name == github.repository && - ( - github.event.pull_request.author_association == 'OWNER' || - github.event.pull_request.author_association == 'MEMBER' || - github.event.pull_request.author_association == 'COLLABORATOR' - ) && - !startsWith(github.event.pull_request.title, 'chore: bump version') && - !startsWith(github.event.pull_request.head.ref, 'chore/bump-version') - )) && - (github.event_name != 'push' || ( - !contains(github.event.head_commit.message, 'chore: bump version') - )) - runs-on: depot-ubuntu-24.04-4 - env: - AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - VISION_MODEL_NAME: ${{ secrets.VISION_MODEL_NAME }} - BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} - BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.5 - - - name: Install dependencies - run: | - set -euo pipefail - bun install --frozen-lockfile || ( - echo "bun install failed; clearing Bun cache and retrying" >&2 - bun pm cache rm - bun install --frozen-lockfile - ) - - - name: Install Playwright browsers - working-directory: packages/backend - run: bunx playwright install --with-deps - - - name: Run Convex tests - working-directory: packages/backend - run: bun run test - - - name: Generate test report - if: always() - working-directory: packages/backend - run: bun run test:report - - - name: Set artifact name - if: always() - run: | - echo "BACKEND_ARTIFACT_NAME=test-results-packages-backend-convex-$(date -u +%Y%m%d-%H%M%S)" >> "$GITHUB_ENV" - - - name: Publish test summary - if: always() - run: | - echo "## Backend Test Summary" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - cat packages/backend/test-results/summary.md | tee /tmp/backend-summary.md - cat /tmp/backend-summary.md >> "$GITHUB_STEP_SUMMARY" - - - name: Upload test artifacts - if: always() - uses: actions/upload-artifact@v5 - with: - name: ${{ env.BACKEND_ARTIFACT_NAME }} - path: packages/backend/test-results - if-no-files-found: warn diff --git a/.github/workflows/e2e-api.yml b/.github/workflows/e2e-api.yml deleted file mode 100644 index e6807183..00000000 --- a/.github/workflows/e2e-api.yml +++ /dev/null @@ -1,229 +0,0 @@ -# E2E API - Venom API tests on Vercel preview deploys -# Trigger: deployment_status (Vercel webhook) -# Parallel: runs alongside e2e-web on preview deploy -name: e2e-api - -on: - deployment_status: - -permissions: - contents: read - pull-requests: read - -jobs: - api-tests: - if: > - github.event.deployment_status.state == 'success' && - github.event.deployment_status.environment_url != '' && - (startsWith(github.event.deployment_status.environment_url, 'https://') || - startsWith(github.event.deployment_status.environment_url, 'http://')) && - (github.event.deployment.environment == 'Preview' || github.event.deployment.environment == 'preview') - runs-on: depot-ubuntu-24.04 - timeout-minutes: 20 - steps: - - name: Check preview test policy - id: doc_check - uses: actions/github-script@v9 - with: - script: | - const { owner, repo } = context.repo; - const sha = - context.payload.deployment?.sha ?? - context.payload.deployment_status?.deployment?.sha; - if (!sha) { - throw new Error( - "No deployment SHA found; cannot evaluate E2E API policy." - ); - } - let prs = []; - try { - const response = - await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner, - repo, - commit_sha: sha, - }); - prs = response.data ?? []; - } catch (error) { - throw new Error( - `Failed to resolve PR for ${sha}: ${error.message}` - ); - } - if (!prs.length) { - throw new Error( - `No PR found for ${sha}; cannot evaluate E2E API policy.` - ); - } - const pr = prs[0]; - const prNumber = pr.number; - const sameRepo = pr.head?.repo?.full_name === `${owner}/${repo}`; - const trustedAssociation = ["OWNER", "MEMBER", "COLLABORATOR"].includes( - pr.author_association - ); - const isDependabot = pr.user?.login === "dependabot[bot]"; - if (!sameRepo || !trustedAssociation || isDependabot) { - core.notice( - `PR #${prNumber} is not trusted for preview E2E; skipping API tests.` - ); - core.setOutput("run_tests", "false"); - return; - } - const isBumpPr = - pr.title?.startsWith("chore: bump version") || - pr.head?.ref?.startsWith("chore/bump-version"); - if (isBumpPr) { - core.notice("Bump PR detected; skipping E2E API tests."); - core.setOutput("run_tests", "false"); - return; - } - const files = await github.paginate( - github.rest.pulls.listFiles, - { owner, repo, pull_number: prNumber, per_page: 100 } - ); - const docOnly = files.every( - (file) => - file.filename === "README.md" || - file.filename === "AGENTS.md" || - file.filename.startsWith("docs/") || - file.filename.startsWith(".codex/") || - file.filename.startsWith("packages/opencode-excalidraw/") || - file.filename.startsWith(".github/workflows/opencode-excalidraw-") - ); - core.setOutput("run_tests", docOnly ? "false" : "true"); - if (docOnly) { - core.notice("Docs-only change; skipping E2E API tests."); - } - - - name: Checkout - if: steps.doc_check.outputs.run_tests == 'true' - uses: actions/checkout@v6 - - - name: Setup Bun - if: steps.doc_check.outputs.run_tests == 'true' - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.3.5" - - - name: Install Dependencies - if: steps.doc_check.outputs.run_tests == 'true' - run: bun install --frozen-lockfile - - - name: Install Playwright browsers - if: steps.doc_check.outputs.run_tests == 'true' - run: ./packages/backend/node_modules/.bin/playwright install --with-deps - - - name: Install Venom - if: steps.doc_check.outputs.run_tests == 'true' - run: | - curl -L -o venom https://github.com/ovh/venom/releases/download/v1.3.0/venom.linux-amd64 - chmod +x venom - sudo mv venom /usr/local/bin/ - - - name: Run API tests - if: steps.doc_check.outputs.run_tests == 'true' - env: - BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} - SKETCHI_E2E_EMAIL: ${{ secrets.SKETCHI_E2E_EMAIL }} - SKETCHI_E2E_PASSWORD: ${{ secrets.SKETCHI_E2E_PASSWORD }} - VENOM_VAR_target_url: ${{ github.event.deployment_status.environment_url }} - VENOM_VAR_bypass_secret: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} - run: | - if [ -z "$BYPASS_SECRET" ]; then - echo "Missing required secret: VERCEL_AUTOMATION_BYPASS_SECRET" - exit 1 - fi - if [ -z "$SKETCHI_E2E_EMAIL" ]; then - echo "Missing required secret: SKETCHI_E2E_EMAIL" - exit 1 - fi - if [ -z "$SKETCHI_E2E_PASSWORD" ]; then - echo "Missing required secret: SKETCHI_E2E_PASSWORD" - exit 1 - fi - - API_AUTH_TOKEN="$( - TARGET_URL="${{ github.event.deployment_status.environment_url }}" \ - BYPASS_SECRET="$BYPASS_SECRET" \ - SKETCHI_E2E_EMAIL="$SKETCHI_E2E_EMAIL" \ - SKETCHI_E2E_PASSWORD="$SKETCHI_E2E_PASSWORD" \ - bun packages/backend/scripts/mint-device-token.mjs - )" - if [ -z "$API_AUTH_TOKEN" ]; then - echo "Failed to mint auth token via device flow." - exit 1 - fi - - echo "::add-mask::$BYPASS_SECRET" - echo "::add-mask::$API_AUTH_TOKEN" - export VENOM_VAR_auth_token="$API_AUTH_TOKEN" - venom run tests/api/auth-device.yml tests/api/diagrams.yml \ - --output-dir tests/api/results \ - --format xml - - - name: Collect Venom log - if: always() && steps.doc_check.outputs.run_tests == 'true' - run: | - mkdir -p tests/api/results - if [ -f venom.log ]; then - cp venom.log tests/api/results/venom.log - fi - - - name: Summarize API tests - if: always() && steps.doc_check.outputs.run_tests == 'true' - run: | - node -e " - const fs = require('fs'); - const path = require('path'); - const resultsDir = path.join(process.cwd(), 'tests', 'api', 'results'); - const summaryPath = process.env.GITHUB_STEP_SUMMARY; - const lines = []; - lines.push('## API Test Results'); - if (!fs.existsSync(resultsDir)) { - lines.push('No results directory found.'); - fs.appendFileSync(summaryPath, lines.join('\\n') + '\\n'); - process.exit(0); - } - const xmlFiles = fs.readdirSync(resultsDir).filter((f) => f.endsWith('.xml')); - if (xmlFiles.length === 0) { - lines.push('No XML results found.'); - fs.appendFileSync(summaryPath, lines.join('\\n') + '\\n'); - process.exit(0); - } - let totalTests = 0; - let totalFailures = 0; - const testcases = []; - for (const file of xmlFiles) { - const xml = fs.readFileSync(path.join(resultsDir, file), 'utf8'); - const testsMatch = xml.match(/tests=\\\"(\\d+)\\\"/); - if (testsMatch) { - totalTests += Number(testsMatch[1] || 0); - } - const failureCount = - (xml.match(/]*name=\\\"([^\\\"]+)\\\"/g; - let match; - while ((match = caseRegex.exec(xml)) !== null) { - testcases.push(match[1]); - } - } - lines.push('Total tests: ' + totalTests); - lines.push('Failures: ' + totalFailures); - if (testcases.length > 0) { - lines.push('Testcases:'); - for (const name of testcases) { - lines.push('- ' + name); - } - } - fs.appendFileSync(summaryPath, lines.join('\\n') + '\\n'); - " - - - name: Upload API test artifacts - if: always() && steps.doc_check.outputs.run_tests == 'true' - uses: actions/upload-artifact@v5 - with: - name: api-test-results-${{ github.run_id }} - path: tests/api/results - if-no-files-found: warn - retention-days: 7 diff --git a/.github/workflows/e2e-web.yml b/.github/workflows/e2e-web.yml deleted file mode 100644 index 3e98a057..00000000 --- a/.github/workflows/e2e-web.yml +++ /dev/null @@ -1,381 +0,0 @@ -# E2E Web - Stagehand browser tests on Vercel preview deploys -# Trigger: deployment_status (Vercel webhook) -# Parallel: runs alongside e2e-api on preview deploy -name: e2e-web - -on: - deployment_status: - -permissions: - contents: read - pull-requests: write - -jobs: - e2e: - if: > - github.event.deployment_status.state == 'success' && - github.event.deployment_status.environment_url != '' && - (startsWith(github.event.deployment_status.environment_url, 'https://') || - startsWith(github.event.deployment_status.environment_url, 'http://')) && - (github.event.deployment.environment == 'Preview' || github.event.deployment.environment == 'preview') - runs-on: depot-ubuntu-24.04 - timeout-minutes: 35 - steps: - - name: Check preview test policy - id: doc_check - uses: actions/github-script@v9 - with: - script: | - const { owner, repo } = context.repo; - const sha = - context.payload.deployment?.sha ?? - context.payload.deployment_status?.deployment?.sha; - if (!sha) { - throw new Error( - "No deployment SHA found; cannot evaluate E2E web policy." - ); - } - let prs = []; - try { - const response = - await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner, - repo, - commit_sha: sha, - }); - prs = response.data ?? []; - } catch (error) { - throw new Error( - `Failed to resolve PR for ${sha}: ${error.message}` - ); - } - if (!prs.length) { - throw new Error( - `No PR found for ${sha}; cannot evaluate E2E web policy.` - ); - } - const pr = prs[0]; - const prNumber = pr.number; - const sameRepo = pr.head?.repo?.full_name === `${owner}/${repo}`; - const trustedAssociation = ["OWNER", "MEMBER", "COLLABORATOR"].includes( - pr.author_association - ); - const isDependabot = pr.user?.login === "dependabot[bot]"; - if (!sameRepo || !trustedAssociation || isDependabot) { - core.notice( - `PR #${prNumber} is not trusted for preview E2E; skipping web tests.` - ); - core.setOutput("run_tests", "false"); - return; - } - const isBumpPr = - pr.title?.startsWith("chore: bump version") || - pr.head?.ref?.startsWith("chore/bump-version"); - if (isBumpPr) { - core.notice("Bump PR detected; skipping E2E web tests."); - core.setOutput("run_tests", "false"); - return; - } - const files = await github.paginate( - github.rest.pulls.listFiles, - { owner, repo, pull_number: prNumber, per_page: 100 } - ); - const docOnly = files.every( - (file) => - file.filename === "README.md" || - file.filename === "AGENTS.md" || - file.filename.startsWith("docs/") || - file.filename.startsWith(".codex/") || - file.filename.startsWith("packages/opencode-excalidraw/") || - file.filename.startsWith(".github/workflows/opencode-excalidraw-") - ); - core.setOutput("run_tests", docOnly ? "false" : "true"); - if (docOnly) { - core.notice("Docs-only change; skipping E2E web tests."); - } - - - name: Checkout - if: steps.doc_check.outputs.run_tests == 'true' - uses: actions/checkout@v6 - - - name: Setup Bun - if: steps.doc_check.outputs.run_tests == 'true' - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install E2E dependencies - if: steps.doc_check.outputs.run_tests == 'true' - run: bun install --frozen-lockfile - working-directory: tests/e2e - - - name: Run Stagehand smoke scenarios - if: steps.doc_check.outputs.run_tests == 'true' - env: - STAGEHAND_TARGET_URL: ${{ github.event.deployment_status.environment_url }} - STAGEHAND_ENV: BROWSERBASE - STAGEHAND_HEADLESS: "true" - BROWSERBASE_SESSION_TIMEOUT_SECONDS: "900" - BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} - BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - MODEL_NAME: ${{ secrets.MODEL_NAME }} - VISION_MODEL_NAME: ${{ secrets.VISION_MODEL_NAME }} - STAGEHAND_VISUAL_STRICT: "false" - VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} - run: | - cd tests/e2e - # Smoke/sanity coverage: fast navigation/auth gating checks. - bun run visual-sanity - bun run auth-gates - - - name: Validate authenticated E2E secrets - if: steps.doc_check.outputs.run_tests == 'true' - env: - SKETCHI_E2E_EMAIL: ${{ secrets.SKETCHI_E2E_EMAIL }} - SKETCHI_E2E_PASSWORD: ${{ secrets.SKETCHI_E2E_PASSWORD }} - run: | - if [ -z "$SKETCHI_E2E_EMAIL" ]; then - echo "Missing required secret: SKETCHI_E2E_EMAIL" - exit 1 - fi - if [ -z "$SKETCHI_E2E_PASSWORD" ]; then - echo "Missing required secret: SKETCHI_E2E_PASSWORD" - exit 1 - fi - echo "::add-mask::$SKETCHI_E2E_EMAIL" - echo "::add-mask::$SKETCHI_E2E_PASSWORD" - - - name: Run Stagehand authenticated continuity scenarios - if: steps.doc_check.outputs.run_tests == 'true' - env: - STAGEHAND_TARGET_URL: ${{ github.event.deployment_status.environment_url }} - STAGEHAND_ENV: BROWSERBASE - STAGEHAND_HEADLESS: "true" - BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} - BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - MODEL_NAME: ${{ secrets.MODEL_NAME }} - VISION_MODEL_NAME: ${{ secrets.VISION_MODEL_NAME }} - STAGEHAND_VISUAL_STRICT: "false" - VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} - SKETCHI_E2E_EMAIL: ${{ secrets.SKETCHI_E2E_EMAIL }} - SKETCHI_E2E_PASSWORD: ${{ secrets.SKETCHI_E2E_PASSWORD }} - run: | - cd tests/e2e - # Assertion coverage: strict CLI<->web continuity + conflict recovery behavior. - bun run opencode-web-continuity - bun run diagram-studio-happy-path - bun run diagram-studio-occ-conflict - - - name: Summarize Browserbase replay links - if: always() && steps.doc_check.outputs.run_tests == 'true' - run: | - node <<'NODE' - const fs = require("fs"); - const path = require("path"); - - const summaryPath = process.env.GITHUB_STEP_SUMMARY; - const replayPath = path.join( - process.cwd(), - "tests", - "e2e", - "artifacts", - "browserbase-sessions.jsonl" - ); - - if (!summaryPath) { - process.exit(0); - } - - const lines = []; - lines.push("## Browserbase Replays"); - - if (!fs.existsSync(replayPath)) { - lines.push("No Browserbase session metadata found."); - fs.appendFileSync(summaryPath, `${lines.join("\n")}\n`); - process.exit(0); - } - - const raw = fs - .readFileSync(replayPath, "utf8") - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - const seen = new Set(); - const sessions = []; - for (const line of raw) { - try { - const entry = JSON.parse(line); - const key = - entry.sessionId || - entry.sessionUrl || - `${entry.scenario || "unknown"}:${entry.capturedAt || ""}`; - if (seen.has(key)) continue; - seen.add(key); - sessions.push(entry); - } catch { - // ignore malformed lines - } - } - - if (!sessions.length) { - lines.push("No Browserbase sessions recorded."); - fs.appendFileSync(summaryPath, `${lines.join("\n")}\n`); - process.exit(0); - } - - for (const session of sessions) { - const idText = session.sessionId - ? `\`${session.sessionId}\`` - : "`unknown`"; - const scenarioText = session.scenario - ? ` (scenario: \`${session.scenario}\`)` - : ""; - const links = []; - if (session.sessionUrl) { - links.push(`[replay](${session.sessionUrl})`); - } - if (session.debugUrl) { - links.push(`[debug](${session.debugUrl})`); - } - lines.push(`- Session ${idText}${scenarioText}: ${links.join(" | ") || "link unavailable"}`); - } - - fs.appendFileSync(summaryPath, `${lines.join("\n")}\n`); - NODE - - - name: Comment Browserbase replay links on PR - if: always() && steps.doc_check.outputs.run_tests == 'true' - uses: actions/github-script@v9 - with: - script: | - const fs = require("fs"); - const path = require("path"); - - const marker = ""; - const replayPath = path.join( - process.cwd(), - "tests", - "e2e", - "artifacts", - "browserbase-sessions.jsonl" - ); - if (!fs.existsSync(replayPath)) { - core.info("No Browserbase replay file found; skipping PR comment."); - return; - } - - const sha = - context.payload.deployment?.sha ?? - context.payload.deployment_status?.deployment?.sha; - if (!sha) { - core.info("No deployment SHA available; skipping PR comment."); - return; - } - - let prs = []; - try { - const response = - await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: sha, - }); - prs = response.data ?? []; - } catch (error) { - core.warning(`Failed to resolve PR from deployment SHA: ${error.message}`); - return; - } - - if (!prs.length) { - core.info(`No PR associated with ${sha}; skipping replay comment.`); - return; - } - - const records = fs - .readFileSync(replayPath, "utf8") - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .flatMap((line) => { - try { - return [JSON.parse(line)]; - } catch { - return []; - } - }); - if (!records.length) { - core.info("Replay file exists but has no valid records."); - return; - } - - const unique = []; - const seen = new Set(); - for (const record of records) { - const key = - record.sessionId || - record.sessionUrl || - `${record.scenario || "unknown"}:${record.capturedAt || ""}`; - if (seen.has(key)) continue; - seen.add(key); - unique.push(record); - } - - const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const lines = [ - marker, - "## Browserbase Replays", - "", - ...unique.map((record) => { - const idText = record.sessionId ? `\`${record.sessionId}\`` : "`unknown`"; - const scenarioText = record.scenario ? ` (scenario: \`${record.scenario}\`)` : ""; - const links = []; - if (record.sessionUrl) links.push(`[replay](${record.sessionUrl})`); - if (record.debugUrl) links.push(`[debug](${record.debugUrl})`); - return `- Session ${idText}${scenarioText}: ${links.join(" | ") || "link unavailable"}`; - }), - "", - `Workflow run: [${context.runId}](${runUrl})`, - ]; - const body = lines.join("\n"); - - const prNumber = prs[0].number; - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - per_page: 100, - }); - - const existing = comments.find((comment) => - comment.user?.type === "Bot" && typeof comment.body === "string" && comment.body.includes(marker) - ); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - core.info(`Updated replay comment on PR #${prNumber}.`); - return; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body, - }); - core.info(`Created replay comment on PR #${prNumber}.`); - - - name: Upload E2E artifacts - if: always() && steps.doc_check.outputs.run_tests == 'true' - uses: actions/upload-artifact@v5 - with: - name: e2e-artifacts-${{ github.run_id }} - path: tests/e2e/artifacts - if-no-files-found: warn - retention-days: 7 diff --git a/.github/workflows/v2-ci.yml b/.github/workflows/v2-ci.yml new file mode 100644 index 00000000..a696d1e0 --- /dev/null +++ b/.github/workflows/v2-ci.yml @@ -0,0 +1,38 @@ +name: v2-ci + +on: + pull_request: + push: + branches: + - main + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + with: + version: 11.5.0 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.13 + + - run: pnpm install --frozen-lockfile + + - run: pnpm --filter @sketchi/backend exec playwright install --with-deps chromium + + - run: pnpm nx run-many -t typecheck,test,build + + - run: pnpm nx run-many -t typecheck-tsgo + + - run: pnpm nx test-storybook diagram-studio-ui + + - run: pnpm exec wrangler deploy --dry-run --config dist/server/wrangler.json diff --git a/.gitignore b/.gitignore index a598569c..2bdc01dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,23 @@ # Dependencies node_modules -.DS_Store -packages/backend/test-results/ .pnp .pnp.js # Build outputs dist build +storybook-static +.output +.wrangler +worker-configuration.d.ts !packages/opencode-excalidraw/.mise/tasks/build *.tsbuildinfo +vitest.config.*.timestamp* # Environment variables .env .env*.local -.env.e2e +.dev.vars # IDEs and editors .vscode/* @@ -37,8 +40,8 @@ yarn-error.log* lerna-debug.log* .pnpm-debug.log* -# Turbo -.turbo +# Nx +.nx # Better-T-Stack .alchemy @@ -46,9 +49,7 @@ lerna-debug.log* # Testing coverage .nyc_output -tests/e2e/artifacts/ -tests/output/ -preview-artifacts/ +packages/backend/test-results/ # Misc *.tgz @@ -69,7 +70,6 @@ temp /sketchi/opencode-logs*/ opencode-logs/ opencode-logs*/ -.vercel # Generated diagram exports /diagram-*.png diff --git a/.ignore b/.ignore index b11f750e..4b7f0d6f 100644 --- a/.ignore +++ b/.ignore @@ -1,8 +1 @@ -# Re-include agent working state for OpenCode indexing (ripgrep) -# These are gitignored but agents need to search/read them -!.sisyphus/ -!.sisyphus/** -!.agents/skills -!.agents/skills/** -!.memory/ !.memory/** diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..e26f0b3f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +# Add files here to ignore them from prettier formatting +/dist +/coverage +/.nx/cache +/.nx/workspace-data \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..bf357fbb --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "trailingComma": "all" +} diff --git a/.vercelignore b/.vercelignore deleted file mode 100644 index e6ebad07..00000000 --- a/.vercelignore +++ /dev/null @@ -1,13 +0,0 @@ -.github -.memory -.turbo -.vscode -.zed -.sisyphus -.codex -.claude -node_modules -tests -docs -venom.log -venom.0.log diff --git a/AGENTS.md b/AGENTS.md index 2a87f4ae..f0df5dd8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,20 +1,22 @@ -## Repo Constraints -- **Branches**: only one active branch other than main at a time (cleanup or recommend cleanup if found in violation) -- **Vercel**: `NEXT_PUBLIC_CONVEX_URL` is automatic. If undefined, Convex deploy failed. -- **Pre-push**: `bun x ultracite fix`, `bun run check-types`, `bun run build`, and `cd packages/backend && bun run test`. +# Agent Guidelines -## Preferences -- **Communication**: Succinct; fragments OK; facts first; show evidence (commands + exit codes). -- **Engineering**: `readable > clever`; long descriptive names OK; split files at ~400 lines. +## Operating Model -## Testing Strategy -- **Priority**: API > E2E > manual/verification. -- **API (Convex)**: `packages/backend/convex/*.test.ts`. Never mock HTTP; verify functional intent. -- **E2E (Stagehand)**: Prompt-first selectors; avoid brittle CSS. Use `STAGEHAND_TARGET_URL` for previews. -- **Authenticated local E2E**: When a flow requires WorkOS sign-in, use `SKETCHI_E2E_EMAIL` and `SKETCHI_E2E_PASSWORD` from local env files instead of ad hoc credentials. -- **Local auth/editor overrides**: For local WorkOS + Convex verification, prefer `SKETCHI_ADMIN_SUBJECTS` / `SKETCHI_ICON_LIBRARY_EDITOR_SUBJECTS` in addition to email allowlists. Local Convex identities may not include email claims even when the user is signed in. -- **UI verification**: For any UI/E2E-affecting change, run a targeted local verification against the real app before finishing. Prefer `agent-browser` for the interaction path and `d3k` for browser/server log review; at minimum run the real dev server with `bun run dev` and verify the affected flow there. -- **Manual**: Checklist + log analysis (`venom.log` or Convex logs). +- This repository is the clean v2 lab for Sketchi. +- Keep the original `shpitdev/sketchi` repository as the production/stars source of truth until the final one-shot integration PR. +- Prefer package boundaries and proof over framework-level shortcuts. +- Temporary artifacts belong in `.memory/`, which is gitignored but intentionally visible to local tools. -## Memory -- Use `.memory/` for temporary artifacts (gitignored but visible to tools). +## V2 Priorities + +- Nx project graph first. +- TanStack Start web shell on Cloudflare Workers. +- Storybook for reusable UI before rebuilding app workflows. +- Diagram IR, renderer, fixtures, and eval surfaces as standalone packages. +- Generic MCP/API should consume package contracts, not UI internals. + +## Checks + +- `pnpm nx run-many -t typecheck,test,build` +- `pnpm nx build-storybook diagram-studio-ui` +- For app changes, run `pnpm nx dev web` and verify the real page locally. diff --git a/README.md b/README.md index 09996544..4337b71d 100644 --- a/README.md +++ b/README.md @@ -1,330 +1,33 @@ -# Sketchi +# Sketchi V2 Lab -

- Sketchi -

+Clean-room rewrite workspace for Sketchi. - -[![Next.js](https://img.shields.io/badge/Next.js-16.1.1-000000?logo=nextdotjs&logoColor=white)](https://nextjs.org/docs) -[![React](https://img.shields.io/badge/React-19.2.3-61DAFB?logo=react&logoColor=white)](https://react.dev/) -[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/docs/) -[![Zod](https://img.shields.io/badge/Zod-4.1.13-3E67B1?logo=zod&logoColor=white)](https://zod.dev/) -[![Convex](https://img.shields.io/badge/Convex-1.31.2-F3694C?logo=convex&logoColor=white)](https://docs.convex.dev/) -[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-4.1.10-06B6D4?logo=tailwindcss&logoColor=white)](https://tailwindcss.com/docs) +The final star-bearing project remains `shpitdev/sketchi`; this fork is the lab where the v2 architecture can be built without legacy noise and then landed as a one-shot integration PR. - -[![Turborepo](https://img.shields.io/badge/Turborepo-2.6.3-EF4444?logo=turborepo&logoColor=white)](https://turbo.build/repo/docs) -[![Better T Stack](https://img.shields.io/badge/Better_T_Stack-Scaffold-FF6B6B?logo=npm&logoColor=white)](https://github.com/AmanVarshney01/create-better-t-stack) -[![Bun](https://img.shields.io/badge/Bun-1.3.5-FBF0DF?logo=bun&logoColor=black)](https://bun.sh/docs) -[![Biome](https://img.shields.io/badge/Biome-2.3.11-60A5FA?logo=biome&logoColor=white)](https://biomejs.dev/guides/getting-started/) -[![OpenCode](https://img.shields.io/badge/OpenCode-CLI-10B981?logo=terminal&logoColor=white)](https://github.com/opencode-ai/opencode) -[![DuckDB](https://img.shields.io/badge/DuckDB-Data%20Analytics-FFF000?logo=duckdb&logoColor=black)](https://duckdb.org/) +## Stack - -[![Vercel AI SDK](https://img.shields.io/badge/Vercel_AI_SDK-6.0.49-000000?logo=vercel&logoColor=white)](https://sdk.vercel.ai/docs) -[![OpenRouter](https://img.shields.io/badge/OpenRouter-API-6366F1?logo=openai&logoColor=white)](https://openrouter.ai/docs) +- pnpm workspaces +- Nx project graph +- TanStack Start app shell +- Cloudflare Workers deployment target +- Storybook for reusable React UI packages +- Package-owned diagram IR, renderer, fixtures, and tests - -[![Excalidraw](https://img.shields.io/badge/Excalidraw-0.18.0-6965DB?logo=excalidraw&logoColor=white)](https://docs.excalidraw.com/) -[![svg2roughjs](https://img.shields.io/badge/svg2roughjs-3.2.1-FF6B6B?logo=svg&logoColor=white)](https://github.com/nickreese/svg2roughjs) -[![dagre](https://img.shields.io/badge/dagre-0.8.5-4ECDC4?logo=graphql&logoColor=white)](https://github.com/dagrejs/dagre/wiki) - - -[![Stagehand](https://img.shields.io/badge/Stagehand-3.0.7-8B5CF6?logo=playwright&logoColor=white)](https://docs.stagehand.dev/) -[![Browserbase](https://img.shields.io/badge/Browserbase-2.6.0-FF4785?logo=google-chrome&logoColor=white)](https://docs.browserbase.com/) -[![Venom](https://img.shields.io/badge/Venom-API_Testing-FF9500?logo=go&logoColor=white)](https://github.com/ovh/venom) -[![Playwright](https://img.shields.io/badge/Playwright-1.58.0-2EAD33?logo=playwright&logoColor=white)](https://playwright.dev/docs/intro) -[![Vitest](https://img.shields.io/badge/Vitest-4.0.18-6E9F18?logo=vitest&logoColor=white)](https://vitest.dev/guide/) -[![GitHub Actions](https://img.shields.io/badge/GitHub_Actions-CI%2FCD-2088FF?logo=githubactions&logoColor=white)](https://docs.github.com/en/actions) - - -[![Deployed on Vercel](https://img.shields.io/badge/Deployed_on-Vercel-000000?logo=vercel&logoColor=white)](https://sketchi.app/) -[![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) - ---- - -## What is Sketchi? - -**Sketchi** is a diagram and icon library toolkit that transforms SVGs into hand-drawn Excalidraw assets. Build icon libraries, generate AI-powered diagrams, and export production-ready `.excalidrawlib` files. - -**Key Features:** -- **Icon Library Generator** - Upload SVGs, customize styles, export as Excalidraw libraries -- **AI Diagram Generation** - Convert natural language to flowcharts, architecture diagrams, and more -- **Hand-drawn Rendering** - Apply sketchy, hand-drawn aesthetics to any SVG -- **Real-time Collaboration** - Powered by Convex for instant sync - -**Live at:** [https://sketchi.app/](https://sketchi.app/) - ---- - -## Screenshots - -

- Sketchi Home -
- Home - Tool selection dashboard -

- -

- Icon Library Generator -
- Icon Library Generator - Build and export Excalidraw libraries -

- -

- AI Diagram Generator -
- AI Diagram Generator - Convert natural language to flowcharts and diagrams -

- ---- - -## Table of Contents - -- [What is Sketchi?](#what-is-sketchi) -- [Screenshots](#screenshots) -- [Architecture](#architecture) -- [Model Strategy](#model-strategy) -- [Features](#features) -- [Quick Start](#quick-start) -- [Development](#development) -- [Testing](#testing) -- [Project Structure](#project-structure) -- [License](#license) - ---- - -## Architecture - -

- Sketchi Architecture -
- Architecture - System design and data flow -

- -**Monorepo Structure:** - -| Directory | Purpose | -|-----------|---------| -| `apps/web` | Next.js 16 frontend with React 19 | -| `packages/backend` | Convex functions, actions, and database schema | -| `packages/env` | Shared environment variable validation | -| `packages/config` | Shared TypeScript configuration | -| `tests/e2e` | End-to-end tests with Stagehand | - -**Data Flow:** -1. User interacts with the Next.js frontend -2. Frontend calls Convex functions via React hooks (`useQuery`, `useMutation`) -3. Convex functions handle business logic, AI calls, and database operations -4. File uploads stored in Convex storage, metadata in Convex database -5. AI diagram generation uses Vercel AI SDK through Convex actions - ---- - -## Model Strategy - -Sketchi uses a multi-model approach via OpenRouter to balance reasoning, vision, and latency. - -| Role | Model | Primary Use Case | -|------|-------|------------------| -| **Brain** | `google/gemini-3-flash-preview` | Diagram generation, structural analysis, and visual grading. | -| **Driver** | `google/gemini-2.5-flash-lite` | E2E test execution (Stagehand) and UI interaction. | -| **Experimental** | `google/gemini-3.1-flash-lite-preview` | Low-latency validation, JSON repair, and classification. | -| **Fallback** | `z-ai/glm-4.7` | High-reliability secondary model for diagram retries. | - ---- - -## Features - -### Icon Library Generator -- **SVG Upload** - Drag and drop or select multiple SVG files -- **Style Customization** - Adjust stroke color, fill style, roughness, and opacity -- **Hand-drawn Conversion** - Transform clean SVGs into sketchy, hand-drawn versions -- **Excalidraw Export** - Download as `.excalidrawlib` for direct import - -### AI Diagram Generation -- **Natural Language Input** - Describe your diagram in plain English -- **Multiple Chart Types** - Flowcharts, architecture diagrams, decision trees -- **Intermediate Format** - Structured JSON representation for predictable layouts -- **Auto Layout** - Dagre-based automatic node positioning - -### Export Options -- **PNG Export** - High-resolution PNG via Browserbase rendering -- **Excalidraw Library** - Reusable icon sets for Excalidraw -- **Share Links** - Generate shareable Excalidraw links - ---- - -## Quick Start - -### Prerequisites - -- [Bun](https://bun.sh/) 1.3.5+ -- [Convex CLI](https://docs.convex.dev/getting-started) - -### Installation - -```bash -# Clone the repository -git clone https://github.com/anand-testcompare/sketchi.git -cd sketchi - -# Install dependencies -bun install - -# Set up Convex (follow prompts to create/link project) -bun run dev:setup - -# Start development servers -bun run dev -``` - -The app will be available at [http://localhost:3001](http://localhost:3001). - -### Environment Variables - -Create `.env.local` in `apps/web/`: +## First Proof Commands ```bash -NEXT_PUBLIC_CONVEX_URL= -``` - -For AI features and testing, see `.env.e2e.example` for additional variables. - ---- - -## OpenCode Plugin - -The publishable OpenCode plugin package lives under `packages/opencode-excalidraw` and is published to npm as `@sketchi-app/opencode-excalidraw`. - -Notes: -- Install Playwright browsers once per machine: - - `npx playwright install` -- Override API base with `SKETCHI_API_URL`. -- Tools exposed: `diagram_from_prompt`, `diagram_tweak`, `diagram_restructure`, `diagram_to_png`, `diagram_grade`. -- Publish channels: `next` is used for pre-release iteration testing; `latest` publishes when the plugin release PR is merged. -- Install and use from any repo: -```bash -npm i @sketchi-app/opencode-excalidraw -``` -- Add to `opencode.json`: -```json -{ - "plugin": ["@sketchi-app/opencode-excalidraw"] -} -``` - ---- - -## Development - -### Commands - -| Command | Description | -|---------|-------------| -| `bun run dev` | Start all apps (frontend + Convex) | -| `bun run dev:web` | Start frontend only | -| `bun run dev:server` | Start Convex backend only | -| `bun run build` | Build all apps | -| `bun run check-types` | TypeScript type checking | -| `bun x ultracite fix` | Format code with Biome | -| `bun x ultracite check` | Lint check with Biome | - -### Code Quality - -This project uses **Ultracite** (Biome preset) for formatting and linting: - -```bash -# Format and fix issues -bun x ultracite fix - -# Check for issues -bun x ultracite check -``` - ---- - -## Testing - -Sketchi follows a **test hierarchy**: API tests > E2E tests > unit tests (last resort). - -### API Tests (Convex Functions) - -Located in `packages/backend/convex/*.test.ts`. Uses Vitest + convex-test. - -```bash -# Run all Convex tests -cd packages/backend -bun run test - -# Watch mode -bun run test:watch - -# Generate report -bun run test:report -``` - -**Test files:** -- `visualGrading.test.ts` - Vision-based diagram grading -- `export.test.ts` - PNG export via Browserbase -- `diagramGenerateFromIntermediate.test.ts` - Diagram rendering -- `diagramLayout.test.ts` - Auto-layout algorithms -- `arrowOptimization.test.ts` - Arrow path optimization - -### E2E Tests (Stagehand + Playwright) - -Located in `tests/e2e/`. Uses Stagehand 3 with LLM-based visual grading. - -```bash -# Set up environment -cp tests/e2e/.env.example tests/e2e/.env.e2e -# Fill in OPENROUTER_API_KEY, BROWSERBASE_API_KEY, etc. - -# Run scenario -cd tests/e2e -bun run visual-sanity -``` - -**Scenarios:** -- `visual-sanity.ts` - Visual sweep across light/dark modes -- `icon-library-generator-happy-path.ts` - Full user flow test - -### CI/CD - -- **Backend tests**: Run on every PR via GitHub Actions -- **E2E tests**: Run against preview deployments -- **Reports**: Uploaded as artifacts to GitHub - ---- - -## Project Structure - -``` -sketchi/ -├── apps/ -│ └── web/ # Next.js 16 frontend -│ ├── src/ -│ │ ├── app/ # App Router pages -│ │ ├── components/ # React components -│ │ └── lib/ # Utilities -│ └── public/ # Static assets -├── packages/ -│ ├── backend/ # Convex backend -│ │ ├── convex/ # Functions, schema, tests -│ │ └── lib/ # Shared utilities -│ ├── config/ # Shared TypeScript config -│ └── env/ # Environment validation -├── tests/ -│ └── e2e/ # Stagehand E2E tests -├── docs/ # Documentation -├── turbo.json # Turborepo config -└── package.json # Root workspace config +pnpm install +pnpm nx run-many -t typecheck,test,build +pnpm nx build-storybook diagram-studio-ui ``` ---- +## Workspace Shape -## License +- `apps/web`: TanStack Start app configured for Cloudflare Workers. +- `packages/diagram-core`: diagram IR schemas, validation, and fixtures. +- `packages/diagram-renderer`: deterministic IR-to-scene rendering. +- `packages/diagram-studio-ui`: reusable UI components and Storybook stories. -MIT License - see [LICENSE](LICENSE) for details. +## Migration Rule -Copyright (c) 2026 Anand Pant | shpit.dev/contact +Only migrate old Sketchi code when it can enter through one of the package contracts above with tests, fixtures, or stories. diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index 9922ee85..00000000 --- a/apps/web/.env.example +++ /dev/null @@ -1,28 +0,0 @@ -# Convex -NEXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud - -# WorkOS AuthKit -WORKOS_CLIENT_ID=client_01XXXXXXXXXXXXXXXXXX -WORKOS_API_KEY=sk_test_XXXXXXXXXX -WORKOS_COOKIE_PASSWORD=generate-with-openssl-rand-base64-24 -NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3001/callback -# Optional: use custom auth domain for device flow (defaults to https://api.workos.com) -WORKOS_DEVICE_AUTH_BASE_URL= - -# Sentry (web + build) -NEXT_PUBLIC_SENTRY_DSN=https://examplePublicKey@o000000.ingest.us.sentry.io/0000000000000000 -SENTRY_AUTH_TOKEN=******** -SENTRY_ORG=your-org-slug -SENTRY_PROJECT=your-project-slug -SENTRY_PUBLIC_KEY=examplePublicKey -SENTRY_OTLP_TRACES_URL=https://o000000.ingest.us.sentry.io/api/0000000000000000/integration/otlp/v1/traces -SENTRY_VERCEL_LOG_DRAIN_URL=https://o000000.ingest.us.sentry.io/api/0000000000000000/integration/vercel/logs/ -SENTRY_DSN=https://examplePrivateKey@o000000.ingest.us.sentry.io/0000000000000000 -SENTRY_LOG_SAMPLE_RATE=0.1 # fraction of logs sampled -SENTRY_CONVEX_ENABLED=0 # enable Convex telemetry (1=on) -SENTRY_CONVEX_MODE=direct # direct|proxy -SKETCHI_TELEMETRY_URL=http://localhost:3001/api/telemetry # telemetry proxy endpoint -# Convex authorization bootstrap. -# Temporary first-admin only; ignored after any Convex user has role="admin". -SKETCHI_BOOTSTRAP_ADMIN_EMAILS=admin@example.com -SKETCHI_BOOTSTRAP_ADMIN_SUBJECTS= diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 7736a19a..6f6e8aa6 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -12,19 +12,14 @@ /coverage # Build outputs -/.next/ -/out/ /build/ /dist/ .vinxi .output -.react-router/ .tanstack/ .nitro/ # Deployment -.vercel -.netlify .wrangler .alchemy @@ -44,7 +39,6 @@ yarn-error.log* # TypeScript *.tsbuildinfo -next-env.d.ts # IDE .vscode/* @@ -56,6 +50,4 @@ dev-dist .wrangler .dev.vars* - -.open-next .env*.local diff --git a/apps/web/components.json b/apps/web/components.json deleted file mode 100644 index d94dbff5..00000000 --- a/apps/web/components.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "base-lyra", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/index.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "menuColor": "default", - "menuAccent": "subtle", - "registries": {} -} diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts deleted file mode 100644 index 826b72b3..00000000 --- a/apps/web/instrumentation.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { captureRequestError } from "@sentry/nextjs"; - -export async function register(): Promise { - // Next.js sets NEXT_RUNTIME to "edge" for Edge Runtime and "nodejs" for Node.js runtime. - // Default to Node.js when the env var isn't present (tests or non-Next execution). - if (process.env.NEXT_RUNTIME === "edge") { - await import("./sentry.edge.config"); - return; - } - - await import("./sentry.server.config"); -} - -// Next.js App Router hook for capturing server request errors. -export const onRequestError = captureRequestError; diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts deleted file mode 100644 index 7481e5d1..00000000 --- a/apps/web/next.config.ts +++ /dev/null @@ -1,43 +0,0 @@ -import "@sketchi/env/web"; -import { withSentryConfig } from "@sentry/nextjs"; -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - typedRoutes: true, - reactCompiler: true, - images: { - remotePatterns: [ - { - protocol: "http", - hostname: "localhost", - }, - { - protocol: "http", - hostname: "127.0.0.1", - }, - { - protocol: "https", - hostname: "*.convex.cloud", - }, - { - protocol: "https", - hostname: "*.convex.site", - }, - ], - }, -}; - -const sentryBuildOptions = { - authToken: process.env.SENTRY_AUTH_TOKEN, - org: process.env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT, - silent: true, - tunnelRoute: "/monitoring", - webpack: { - treeshake: { - removeDebugLogging: true, - }, - }, -}; - -export default withSentryConfig(nextConfig, sentryBuildOptions); diff --git a/apps/web/package.json b/apps/web/package.json index 170457be..74e19953 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,53 +1,10 @@ { - "name": "web", - "version": "0.1.0", + "name": "@sketchi/web", "private": true, - "scripts": { - "dev": "bun run --bun next dev --port 3001", - "build": "bun run --bun next build", - "start": "bun run --bun next start", - "test": "vitest run" - }, + "type": "module", "dependencies": { - "@base-ui/react": "^1.2.0", - "@excalidraw/excalidraw": "^0.18.0", - "@orpc/openapi": "^1.13.5", - "@orpc/server": "^1.13.5", - "@orpc/zod": "^1.13.5", - "@scalar/nextjs-api-reference": "^0.9.22", - "@sentry/nextjs": "^10.39.0", - "@sketchi/backend": "workspace:*", - "@sketchi/env": "workspace:*", - "@sketchi/shared": "workspace:*", - "@vercel/analytics": "^1.6.1", - "@workos-inc/authkit-nextjs": "^2.15.0", - "babel-plugin-react-compiler": "^1.0.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "convex": "catalog:", - "fractional-indexing": "^3.2.0", - "jszip": "^3.10.1", - "lucide-react": "^0.575.0", - "next": "^16.1.6", - "next-themes": "^0.4.6", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "shadcn": "^3.8.5", - "sonner": "^2.0.7", - "svg2roughjs": "^3.2.1", - "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0", - "zod": "catalog:" - }, - "devDependencies": { - "@sketchi/config": "workspace:*", - "@tailwindcss/postcss": "^4.2.0", - "@types/node": "^25.6.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "tailwindcss": "^4.2.0", - "typescript": "catalog:", - "vite-tsconfig-paths": "^6.1.1", - "vitest": "^4.0.18" + "@sketchi/diagram-core": "workspace:*", + "@sketchi/diagram-studio-ui": "workspace:*", + "@tanstack/react-query": "5.101.0" } } diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs deleted file mode 100644 index c7bcb4b1..00000000 --- a/apps/web/postcss.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -const config = { - plugins: ["@tailwindcss/postcss"], -}; - -export default config; diff --git a/apps/web/project.json b/apps/web/project.json new file mode 100644 index 00000000..595b1564 --- /dev/null +++ b/apps/web/project.json @@ -0,0 +1,60 @@ +{ + "name": "web", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/web/src", + "projectType": "application", + "implicitDependencies": ["diagram-core", "diagram-studio-ui"], + "targets": { + "build": { + "executor": "nx:run-commands", + "outputs": ["{projectRoot}/.output"], + "options": { + "command": "vite build --config apps/web/vite.config.ts" + } + }, + "dev": { + "executor": "nx:run-commands", + "options": { + "command": "vite dev --config apps/web/vite.config.ts --host 127.0.0.1" + } + }, + "preview": { + "executor": "nx:run-commands", + "options": { + "command": "vite preview --config apps/web/vite.config.ts --host 127.0.0.1" + } + }, + "deploy": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "command": "wrangler deploy --config dist/server/wrangler.json" + } + }, + "cf-typegen": { + "executor": "nx:run-commands", + "options": { + "command": "wrangler types --config apps/web/wrangler.jsonc" + } + }, + "test": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/coverage/apps/web"], + "options": { + "command": "vitest run --config apps/web/vitest.config.ts" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "tsc -p apps/web/tsconfig.json --noEmit" + } + }, + "typecheck-tsgo": { + "executor": "nx:run-commands", + "options": { + "command": "tsgo -p apps/web/tsconfig.json --noEmit" + } + } + } +} diff --git a/apps/web/public/icons/github-dark-svg.svg b/apps/web/public/icons/github-dark-svg.svg deleted file mode 100644 index ce31b20d..00000000 --- a/apps/web/public/icons/github-dark-svg.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/icons/github-svg.svg b/apps/web/public/icons/github-svg.svg deleted file mode 100644 index d74bbee2..00000000 --- a/apps/web/public/icons/github-svg.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/icons/logo-wide.svg b/apps/web/public/icons/logo-wide.svg deleted file mode 100644 index 07486738..00000000 --- a/apps/web/public/icons/logo-wide.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/icons/logo.svg b/apps/web/public/icons/logo.svg deleted file mode 100644 index c147457c..00000000 --- a/apps/web/public/icons/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/icons/npm-svg.svg b/apps/web/public/icons/npm-svg.svg deleted file mode 100644 index fbb2dd33..00000000 --- a/apps/web/public/icons/npm-svg.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/icons/npm-text-svg.svg b/apps/web/public/icons/npm-text-svg.svg deleted file mode 100644 index 907320f9..00000000 --- a/apps/web/public/icons/npm-text-svg.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/icons/opencode-logo-dark-svg.svg b/apps/web/public/icons/opencode-logo-dark-svg.svg deleted file mode 100644 index 1c264678..00000000 --- a/apps/web/public/icons/opencode-logo-dark-svg.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/icons/opencode-logo-light-svg.svg b/apps/web/public/icons/opencode-logo-light-svg.svg deleted file mode 100644 index 126ca101..00000000 --- a/apps/web/public/icons/opencode-logo-light-svg.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/public/og-image-dark.png b/apps/web/public/og-image-dark.png deleted file mode 100644 index 0114d2c6..00000000 Binary files a/apps/web/public/og-image-dark.png and /dev/null differ diff --git a/apps/web/public/og-image.png b/apps/web/public/og-image.png deleted file mode 100644 index 35b6f3fb..00000000 Binary files a/apps/web/public/og-image.png and /dev/null differ diff --git a/apps/web/public/screenshots/icon-library-js.png b/apps/web/public/screenshots/icon-library-js.png deleted file mode 100644 index 483673c2..00000000 Binary files a/apps/web/public/screenshots/icon-library-js.png and /dev/null differ diff --git a/apps/web/public/screenshots/library-generator.png b/apps/web/public/screenshots/library-generator.png deleted file mode 100644 index 08d285b1..00000000 Binary files a/apps/web/public/screenshots/library-generator.png and /dev/null differ diff --git a/apps/web/public/screenshots/opencode-auth-login-sketchi.png b/apps/web/public/screenshots/opencode-auth-login-sketchi.png deleted file mode 100644 index 49cb5a3d..00000000 Binary files a/apps/web/public/screenshots/opencode-auth-login-sketchi.png and /dev/null differ diff --git a/apps/web/public/screenshots/opencode-preview-dark.png b/apps/web/public/screenshots/opencode-preview-dark.png deleted file mode 100644 index b7f8b163..00000000 Binary files a/apps/web/public/screenshots/opencode-preview-dark.png and /dev/null differ diff --git a/apps/web/public/screenshots/opencode-preview-light.png b/apps/web/public/screenshots/opencode-preview-light.png deleted file mode 100644 index d52c820e..00000000 Binary files a/apps/web/public/screenshots/opencode-preview-light.png and /dev/null differ diff --git a/apps/web/public/screenshots/opencode-terminal-dark.png b/apps/web/public/screenshots/opencode-terminal-dark.png deleted file mode 100644 index b688fb51..00000000 Binary files a/apps/web/public/screenshots/opencode-terminal-dark.png and /dev/null differ diff --git a/apps/web/public/screenshots/opencode-terminal-light.png b/apps/web/public/screenshots/opencode-terminal-light.png deleted file mode 100644 index 8d36145c..00000000 Binary files a/apps/web/public/screenshots/opencode-terminal-light.png and /dev/null differ diff --git a/apps/web/public/screenshots/palantir-foundry-icon-set.png b/apps/web/public/screenshots/palantir-foundry-icon-set.png deleted file mode 100644 index b3597f6a..00000000 Binary files a/apps/web/public/screenshots/palantir-foundry-icon-set.png and /dev/null differ diff --git a/apps/web/public/screenshots/web-based-inline-generator-god-hates-js.png b/apps/web/public/screenshots/web-based-inline-generator-god-hates-js.png deleted file mode 100644 index 6aefe0d2..00000000 Binary files a/apps/web/public/screenshots/web-based-inline-generator-god-hates-js.png and /dev/null differ diff --git a/apps/web/sentry.client.config.ts b/apps/web/sentry.client.config.ts deleted file mode 100644 index f2956a9c..00000000 --- a/apps/web/sentry.client.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { init } from "@sentry/nextjs"; - -const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; - -init({ - dsn, - enabled: Boolean(dsn), - environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV, - tracesSampleRate: 0.1, -}); diff --git a/apps/web/sentry.edge.config.ts b/apps/web/sentry.edge.config.ts deleted file mode 100644 index f2956a9c..00000000 --- a/apps/web/sentry.edge.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { init } from "@sentry/nextjs"; - -const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; - -init({ - dsn, - enabled: Boolean(dsn), - environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV, - tracesSampleRate: 0.1, -}); diff --git a/apps/web/sentry.server.config.ts b/apps/web/sentry.server.config.ts deleted file mode 100644 index f2956a9c..00000000 --- a/apps/web/sentry.server.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { init } from "@sentry/nextjs"; - -const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; - -init({ - dsn, - enabled: Boolean(dsn), - environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV, - tracesSampleRate: 0.1, -}); diff --git a/apps/web/src/app/api/[...orpc]/route.ts b/apps/web/src/app/api/[...orpc]/route.ts deleted file mode 100644 index 6c3e29d0..00000000 --- a/apps/web/src/app/api/[...orpc]/route.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { OpenAPIHandler } from "@orpc/openapi/fetch"; -import { captureException, withScope } from "@sentry/nextjs"; -import { createTraceId, normalizeTraceId } from "@sketchi/shared"; - -import type { LogLevel } from "@/lib/observability"; -import { getOrpcRouteTag, hashString, logApiEvent } from "@/lib/observability"; -import { appRouter, createOrpcContext } from "@/lib/orpc/router"; - -const handler = new OpenAPIHandler(appRouter); - -async function handleRequest(request: Request) { - const start = Date.now(); - const traceId = - normalizeTraceId(request.headers.get("x-trace-id")) ?? createTraceId(); - const url = new URL(request.url); - const orpcRoute = getOrpcRouteTag(url.pathname); - const requestClone = request.clone(); - let requestBody = ""; - if (request.method !== "GET") { - try { - requestBody = await requestClone.text(); - } catch { - requestBody = ""; - } - } - - let response: Response | null = null; - let error: unknown; - - try { - const result = await handler.handle(request, { - prefix: "/api", - context: createOrpcContext(request, { traceIdOverride: traceId }), - }); - response = result.response ?? new Response("Not Found", { status: 404 }); - } catch (err) { - error = err; - response = new Response("Internal Server Error", { status: 500 }); - } - - const responseWithTrace = new Response(response.body, response); - responseWithTrace.headers.set("x-trace-id", traceId); - - const durationMs = Date.now() - start; - const requestHash = await hashString(requestBody); - const requestLength = requestBody.length || undefined; - const status = responseWithTrace.status; - let statusType: "success" | "warning" | "failed" = "success"; - let level: LogLevel = "info"; - if (status >= 500) { - statusType = "failed"; - level = "error"; - } else if (status >= 400) { - statusType = "warning"; - level = "warning"; - } - - if (error) { - withScope((scope) => { - scope.setTag("traceId", traceId); - if (orpcRoute) { - scope.setTag("orpc.route", orpcRoute); - } - scope.setContext("orpc.request", { - method: request.method, - path: url.pathname, - }); - captureException(error); - }); - } - - await logApiEvent( - { - traceId, - op: "request.complete", - status: statusType, - durationMs, - requestLength, - requestHash: requestHash ?? undefined, - responseStatus: status, - method: request.method, - path: url.pathname, - orpcRoute: orpcRoute ?? undefined, - errorName: error instanceof Error ? error.name : undefined, - errorMessage: error instanceof Error ? error.message : undefined, - }, - { level } - ); - - return responseWithTrace; -} - -export { handleRequest as GET, handleRequest as POST }; diff --git a/apps/web/src/app/api/auth/device/start/route.ts b/apps/web/src/app/api/auth/device/start/route.ts deleted file mode 100644 index 7c03c430..00000000 --- a/apps/web/src/app/api/auth/device/start/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createTraceId, normalizeTraceId } from "@sketchi/shared"; -import { startWorkOsDeviceFlow } from "@/lib/workos-device-auth"; - -export async function POST(request: Request) { - const traceId = - normalizeTraceId(request.headers.get("x-trace-id")) ?? createTraceId(); - - try { - const started = await startWorkOsDeviceFlow(); - - return Response.json( - { - deviceCode: started.deviceCode, - userCode: started.userCode, - interval: started.interval, - expiresIn: started.expiresIn, - verificationUrl: started.verificationUrl, - }, - { - headers: { - "cache-control": "no-store", - "x-trace-id": traceId, - }, - } - ); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to start device flow"; - return Response.json( - { error: message }, - { - status: 500, - headers: { - "cache-control": "no-store", - "x-trace-id": traceId, - }, - } - ); - } -} diff --git a/apps/web/src/app/api/auth/device/token/route.ts b/apps/web/src/app/api/auth/device/token/route.ts deleted file mode 100644 index c252c529..00000000 --- a/apps/web/src/app/api/auth/device/token/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { createTraceId, normalizeTraceId } from "@sketchi/shared"; -import { pollWorkOsDeviceFlow } from "@/lib/workos-device-auth"; - -export async function POST(request: Request) { - const traceId = - normalizeTraceId(request.headers.get("x-trace-id")) ?? createTraceId(); - - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json( - { error: "Invalid JSON payload" }, - { - status: 400, - headers: { - "cache-control": "no-store", - "x-trace-id": traceId, - }, - } - ); - } - - const deviceCode = - payload && typeof payload === "object" && "deviceCode" in payload - ? (payload.deviceCode as string) - : undefined; - - if (!(typeof deviceCode === "string" && deviceCode.trim().length > 0)) { - return Response.json( - { error: "deviceCode is required" }, - { - status: 400, - headers: { - "cache-control": "no-store", - "x-trace-id": traceId, - }, - } - ); - } - - try { - const result = await pollWorkOsDeviceFlow({ - deviceCode, - }); - - return Response.json(result, { - headers: { - "cache-control": "no-store", - "x-trace-id": traceId, - }, - }); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to poll device flow"; - return Response.json( - { error: message }, - { - status: 500, - headers: { - "cache-control": "no-store", - "x-trace-id": traceId, - }, - } - ); - } -} diff --git a/apps/web/src/app/api/auth/refresh/route.test.ts b/apps/web/src/app/api/auth/refresh/route.test.ts deleted file mode 100644 index 106f1893..00000000 --- a/apps/web/src/app/api/auth/refresh/route.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const refreshWorkOsAccessToken = vi.fn(); - -vi.mock("@/lib/workos-device-auth", () => ({ - refreshWorkOsAccessToken, -})); - -describe("POST /api/auth/refresh", () => { - beforeEach(() => { - refreshWorkOsAccessToken.mockReset(); - }); - - it("returns 400 for invalid JSON payloads", async () => { - const { POST } = await import("./route"); - const traceId = "11111111-1111-4111-8111-111111111111"; - - const response = await POST( - new Request("http://localhost/api/auth/refresh", { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": traceId, - }, - body: "{", - }) - ); - - expect(response.status).toBe(400); - expect(response.headers.get("x-trace-id")).toBe(traceId); - await expect(response.json()).resolves.toEqual({ - error: "Invalid JSON payload", - }); - }); - - it("returns 400 when refreshToken is missing", async () => { - const { POST } = await import("./route"); - const traceId = "22222222-2222-4222-8222-222222222222"; - - const response = await POST( - new Request("http://localhost/api/auth/refresh", { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": traceId, - }, - body: JSON.stringify({}), - }) - ); - - expect(response.status).toBe(400); - expect(response.headers.get("x-trace-id")).toBe(traceId); - await expect(response.json()).resolves.toEqual({ - error: "refreshToken is required", - }); - expect(refreshWorkOsAccessToken).not.toHaveBeenCalled(); - }); - - it("returns refreshed credentials on success", async () => { - refreshWorkOsAccessToken.mockResolvedValue({ - status: "success", - accessToken: "fresh-access", - refreshToken: "fresh-refresh", - accessTokenExpiresAt: 123, - }); - - const { POST } = await import("./route"); - const traceId = "33333333-3333-4333-8333-333333333333"; - - const response = await POST( - new Request("http://localhost/api/auth/refresh", { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": traceId, - }, - body: JSON.stringify({ refreshToken: "stored-refresh-token" }), - }) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("x-trace-id")).toBe(traceId); - expect(refreshWorkOsAccessToken).toHaveBeenCalledWith({ - refreshToken: "stored-refresh-token", - }); - await expect(response.json()).resolves.toEqual({ - status: "success", - accessToken: "fresh-access", - refreshToken: "fresh-refresh", - accessTokenExpiresAt: 123, - }); - }); - - it("passes through invalid_grant responses", async () => { - refreshWorkOsAccessToken.mockResolvedValue({ - status: "invalid_grant", - }); - - const { POST } = await import("./route"); - const traceId = "44444444-4444-4444-8444-444444444444"; - - const response = await POST( - new Request("http://localhost/api/auth/refresh", { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": traceId, - }, - body: JSON.stringify({ refreshToken: "expired-refresh-token" }), - }) - ); - - expect(response.status).toBe(200); - expect(response.headers.get("x-trace-id")).toBe(traceId); - await expect(response.json()).resolves.toEqual({ - status: "invalid_grant", - }); - }); -}); diff --git a/apps/web/src/app/api/auth/refresh/route.ts b/apps/web/src/app/api/auth/refresh/route.ts deleted file mode 100644 index f67c9ae5..00000000 --- a/apps/web/src/app/api/auth/refresh/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createTraceId, normalizeTraceId } from "@sketchi/shared"; - -import { refreshWorkOsAccessToken } from "@/lib/workos-device-auth"; - -export async function POST(request: Request) { - const traceId = - normalizeTraceId(request.headers.get("x-trace-id")) ?? createTraceId(); - - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json( - { error: "Invalid JSON payload" }, - { - status: 400, - headers: { - "cache-control": "no-store", - "x-trace-id": traceId, - }, - } - ); - } - - const refreshToken = - payload && typeof payload === "object" && "refreshToken" in payload - ? (payload.refreshToken as string) - : undefined; - - if (!(typeof refreshToken === "string" && refreshToken.trim().length > 0)) { - return Response.json( - { error: "refreshToken is required" }, - { - status: 400, - headers: { - "cache-control": "no-store", - "x-trace-id": traceId, - }, - } - ); - } - - try { - const result = await refreshWorkOsAccessToken({ - refreshToken, - }); - - return Response.json(result, { - headers: { - "cache-control": "no-store", - "x-trace-id": traceId, - }, - }); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to refresh access token"; - - return Response.json( - { error: message }, - { - status: 500, - headers: { - "cache-control": "no-store", - "x-trace-id": traceId, - }, - } - ); - } -} diff --git a/apps/web/src/app/api/docs/route.ts b/apps/web/src/app/api/docs/route.ts deleted file mode 100644 index 77a842bc..00000000 --- a/apps/web/src/app/api/docs/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ApiReference } from "@scalar/nextjs-api-reference"; - -export const GET = ApiReference({ - url: "/api/openapi", - theme: "bluePlanet", - layout: "modern", - defaultOpenAllTags: true, - hideClientButton: false, - showSidebar: true, - hideModels: false, - hideTestRequestButton: false, - hideSearch: false, - showOperationId: false, - hideDarkModeToggle: false, - withDefaultFonts: true, - expandAllModelSections: false, - expandAllResponses: false, - // Using explicit known types to avoid potential TS errors with "localhost" etc. - metaData: { - title: "Sketchi API", - }, -}); diff --git a/apps/web/src/app/api/openapi/route.ts b/apps/web/src/app/api/openapi/route.ts deleted file mode 100644 index 7118e1b4..00000000 --- a/apps/web/src/app/api/openapi/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { getOpenApiSpec } from "@/lib/orpc/openapi"; - -export async function GET() { - const spec = await getOpenApiSpec(); - return Response.json(spec); -} diff --git a/apps/web/src/app/api/telemetry/route.ts b/apps/web/src/app/api/telemetry/route.ts deleted file mode 100644 index 21a99f2e..00000000 --- a/apps/web/src/app/api/telemetry/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { createTraceId, normalizeTraceId } from "@sketchi/shared"; -import { z } from "zod"; - -import { logApiEvent } from "@/lib/observability"; - -const TelemetrySchema = z - .object({ - traceId: z.string().optional(), - level: z.enum(["info", "warning", "error"]).optional(), - op: z.string(), - }) - .passthrough(); - -export async function POST(request: Request) { - let payload: unknown = null; - try { - payload = await request.json(); - } catch { - return new Response("Invalid JSON", { status: 400 }); - } - - const parsed = TelemetrySchema.safeParse(payload); - if (!parsed.success) { - return new Response("Invalid payload", { status: 400 }); - } - - const traceId = - normalizeTraceId(parsed.data.traceId) ?? - normalizeTraceId(request.headers.get("x-trace-id")) ?? - createTraceId(); - const level = parsed.data.level ?? "info"; - - const { - level: _level, - traceId: _traceId, - op: _op, - status: _status, - component: _component, - requestLength: _requestLength, - ...extra - } = parsed.data; - - await logApiEvent( - { - ...extra, - traceId, - op: parsed.data.op, - status: "success", - component: "telemetry-proxy", - requestLength: JSON.stringify(payload).length, - }, - { level } - ); - - const response = new Response(JSON.stringify({ status: "ok", traceId }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - response.headers.set("x-trace-id", traceId); - return response; -} diff --git a/apps/web/src/app/callback/route.ts b/apps/web/src/app/callback/route.ts deleted file mode 100644 index 95449baa..00000000 --- a/apps/web/src/app/callback/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { handleAuth } from "@workos-inc/authkit-nextjs"; - -export const GET = handleAuth(); diff --git a/apps/web/src/app/diagrams/[sessionId]/page.tsx b/apps/web/src/app/diagrams/[sessionId]/page.tsx deleted file mode 100644 index 5cd14243..00000000 --- a/apps/web/src/app/diagrams/[sessionId]/page.tsx +++ /dev/null @@ -1,917 +0,0 @@ -"use client"; - -import type { - BinaryFiles, - ExcalidrawImperativeAPI, -} from "@excalidraw/excalidraw/types"; -import { api } from "@sketchi/backend/convex/_generated/api"; -import { useAuth } from "@workos-inc/authkit-nextjs/components"; -import { useMutation, useQuery } from "convex/react"; -import { - AlertTriangle, - Check, - Loader2, - PenTool, - RefreshCw, - Save, -} from "lucide-react"; -import dynamic from "next/dynamic"; -import { useParams, useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; - -import { - ChatSidebar, - type ThreadMessage, -} from "@/components/diagram-studio/chat-sidebar"; -import { ImportExportToolbar } from "@/components/diagram-studio/import-export-toolbar"; -import { sanitizeAppState } from "@/components/diagram-studio/sanitize-app-state"; -import { Button } from "@/components/ui/button"; -import { addDiagramRecent } from "@/lib/diagram-recents"; - -const AUTOSAVE_DELAY_MS = 2000; -const COMPLETION_PULSE_MS = 1500; -const STOP_RETRY_DELAYS_MS = [220, 380, 620]; - -const ExcalidrawEditor = dynamic( - () => import("@/components/diagram-studio/excalidraw-wrapper"), - { ssr: false } -); - -type SaveState = - | { status: "idle" } - | { status: "saving" } - | { status: "saved"; savedAt: number } - | { status: "conflict"; serverVersion: number } - | { status: "error"; message: string }; - -type RunStatus = - | "sending" - | "running" - | "applying" - | "persisted" - | "stopped" - | "error"; - -interface RunState { - promptMessageId: string; - status: RunStatus; - stopRequested: boolean; -} - -type StoredSceneFiles = Record; - -function normalizeSceneFiles( - files: BinaryFiles | StoredSceneFiles | null | undefined -): StoredSceneFiles | undefined { - if (!files) { - return undefined; - } - - if (Object.keys(files).length < 1) { - return undefined; - } - - return files as StoredSceneFiles; -} - -function createSceneFileFingerprint( - files: StoredSceneFiles | undefined -): string { - if (!files) { - return ""; - } - - return Object.entries(files) - .sort(([leftId], [rightId]) => leftId.localeCompare(rightId)) - .map(([fileId, file]) => { - const metadata = - file && typeof file === "object" - ? (file as { - created?: unknown; - mimeType?: unknown; - }) - : {}; - const created = - typeof metadata.created === "number" ? metadata.created : "na"; - const mimeType = - typeof metadata.mimeType === "string" ? metadata.mimeType : "unknown"; - return `${fileId}:${mimeType}:${created}`; - }) - .join("|"); -} - -function createSceneFingerprint(input: { - elements: readonly Record[]; - files?: BinaryFiles | StoredSceneFiles | null; -}): string { - const elementFingerprint = input.elements - .map( - (element) => - `${element.id}:${element.version}:${element.versionNonce}:${element.isDeleted ?? false}` - ) - .join("|"); - const files = normalizeSceneFiles(input.files); - return `${elementFingerprint}::${createSceneFileFingerprint(files)}`; -} - -function createOptimisticUserMessage(input: { - content: string; - promptMessageId: string; -}): ThreadMessage { - const now = Date.now(); - return { - messageId: `optimistic_${input.promptMessageId}`, - promptMessageId: input.promptMessageId, - role: "user", - messageType: "chat", - content: input.content, - reasoningSummary: null, - status: "sending", - toolName: null, - toolCallId: null, - toolInput: null, - toolOutput: null, - traceId: null, - error: null, - createdAt: now, - updatedAt: now, - }; -} - -function isRunActive(status: RunStatus | null): boolean { - return status === "sending" || status === "running" || status === "applying"; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -export default function DiagramStudioPage() { - const { sessionId } = useParams<{ sessionId: string }>(); - const router = useRouter(); - const { user, loading: authLoading } = useAuth(); - const canQuerySession = Boolean(sessionId && user); - - const session = useQuery( - api.diagramSessions.get, - canQuerySession ? { sessionId } : "skip" - ); - const thread = useQuery( - api.diagramThreads.listBySession, - canQuerySession ? { sessionId } : "skip" - ); - - const createSession = useMutation(api.diagramSessions.create); - const enqueuePrompt = useMutation(api.diagramThreads.enqueuePrompt); - const setLatestScene = useMutation(api.diagramSessions.setLatestScene); - const stopPrompt = useMutation(api.diagramThreads.stopPrompt); - const [isCreating, setIsCreating] = useState(false); - - const [excalidrawApi, setExcalidrawApi] = - useState(null); - const [saveState, setSaveState] = useState({ status: "idle" }); - const [autosaveDisabled, setAutosaveDisabled] = useState(false); - const [liveNonDeletedCount, setLiveNonDeletedCount] = useState(0); - const [showCompletionPulse, setShowCompletionPulse] = useState(false); - const [optimisticMessages, setOptimisticMessages] = useState( - [] - ); - const [optimisticRunState, setOptimisticRunState] = useState( - null - ); - - const suppressOnChangeRef = useRef(true); - const initialLoadAppliedRef = useRef(false); - const autosaveTimeoutRef = useRef | null>(null); - const knownVersionRef = useRef(0); - const appliedVersionRef = useRef(null); - const isLocallyDirtyRef = useRef(false); - const lastSceneFingerprintRef = useRef(""); - const pendingSceneRef = useRef<{ - elements: readonly Record[]; - appState: Record; - files?: StoredSceneFiles; - } | null>(null); - const previousRunStatusRef = useRef(null); - - useEffect(() => { - if (sessionId) { - addDiagramRecent(sessionId); - } - }, [sessionId]); - - const threadRunState: RunState | null = useMemo(() => { - if (!thread?.latestRun) { - return null; - } - - return { - promptMessageId: thread.latestRun.promptMessageId, - status: thread.latestRun.status as RunStatus, - stopRequested: thread.latestRun.stopRequested, - }; - }, [thread]); - - useEffect(() => { - if (!optimisticRunState) { - return; - } - if ( - threadRunState && - threadRunState.promptMessageId === optimisticRunState.promptMessageId - ) { - setOptimisticRunState(null); - return; - } - if ( - !(isRunActive(optimisticRunState.status) || threadRunState) && - optimisticRunState.stopRequested - ) { - setOptimisticRunState(null); - } - }, [optimisticRunState, threadRunState]); - - const runState = optimisticRunState ?? threadRunState; - - const activeRunStatus = runState?.status ?? null; - const isProcessing = isRunActive(activeRunStatus); - - const triggerCompletionPulse = useCallback(() => { - setShowCompletionPulse(true); - setTimeout(() => { - setShowCompletionPulse(false); - }, COMPLETION_PULSE_MS); - }, []); - - useEffect(() => { - const previous = previousRunStatusRef.current; - const current = runState?.status ?? null; - - if (previous && previous !== "persisted" && current === "persisted") { - triggerCompletionPulse(); - } - - previousRunStatusRef.current = current; - }, [runState?.status, triggerCompletionPulse]); - - useEffect(() => { - if (!(thread && thread.messages.length > 0)) { - return; - } - - setOptimisticMessages((previous) => - previous.filter((optimistic) => { - const hasPersisted = thread.messages.some( - (message) => - message.role === "user" && - message.promptMessageId === optimistic.promptMessageId - ); - return !hasPersisted; - }) - ); - }, [thread]); - - const saveScene = useCallback( - async ( - elements: readonly Record[], - appState: Record, - files?: StoredSceneFiles, - overrideVersion?: number - ) => { - if (!sessionId) { - return; - } - - setSaveState({ status: "saving" }); - - try { - const result = await setLatestScene({ - sessionId, - expectedVersion: overrideVersion ?? knownVersionRef.current, - elements: elements as Record[], - appState: sanitizeAppState(appState), - files, - }); - - if (result.status === "success") { - knownVersionRef.current = result.latestSceneVersion; - appliedVersionRef.current = result.latestSceneVersion; - isLocallyDirtyRef.current = false; - setAutosaveDisabled(false); - setSaveState({ status: "saved", savedAt: result.savedAt }); - pendingSceneRef.current = null; - } else if (result.status === "conflict") { - isLocallyDirtyRef.current = true; - pendingSceneRef.current = { elements, appState, files }; - setSaveState({ - status: "conflict", - serverVersion: result.latestSceneVersion, - }); - } else if ( - result.status === "failed" && - "reason" in result && - result.reason === "scene-too-large" - ) { - setAutosaveDisabled(true); - const maxKb = Math.round( - (result as { maxBytes: number }).maxBytes / 1024 - ); - const actualKb = Math.round( - (result as { actualBytes: number }).actualBytes / 1024 - ); - toast.error( - `Scene too large (${actualKb} KB). Maximum is ${maxKb} KB. Autosave disabled until scene is reduced.` - ); - setSaveState({ - status: "error", - message: `Too large: ${actualKb}/${maxKb} KB`, - }); - } - } catch { - setSaveState({ status: "error", message: "Save failed" }); - } - }, - [sessionId, setLatestScene] - ); - - const handleChange = useCallback( - ( - elements: readonly Record[], - appState: Record, - files: BinaryFiles - ) => { - const nonDeleted = elements.filter( - (element) => element.isDeleted !== true - ).length; - setLiveNonDeletedCount(nonDeleted); - - if (autosaveDisabled || suppressOnChangeRef.current || isProcessing) { - return; - } - - const normalizedFiles = normalizeSceneFiles(files); - const fingerprint = createSceneFingerprint({ - elements, - files: normalizedFiles, - }); - if (fingerprint === lastSceneFingerprintRef.current) { - return; - } - lastSceneFingerprintRef.current = fingerprint; - isLocallyDirtyRef.current = true; - - if (autosaveTimeoutRef.current) { - clearTimeout(autosaveTimeoutRef.current); - } - - autosaveTimeoutRef.current = setTimeout(() => { - saveScene(elements, appState, normalizedFiles).catch(() => undefined); - }, AUTOSAVE_DELAY_MS); - }, - [autosaveDisabled, isProcessing, saveScene] - ); - - const applySceneToCanvas = useCallback( - (input: { - elements: readonly Record[]; - appState: Record; - files?: StoredSceneFiles; - version: number; - }) => { - if (!excalidrawApi) { - return; - } - - suppressOnChangeRef.current = true; - excalidrawApi.resetScene({ resetLoadingState: false }); - const files = Object.values(input.files ?? {}); - if (files.length > 0) { - excalidrawApi.addFiles( - files as Parameters[0] - ); - } - excalidrawApi.updateScene({ - elements: input.elements as unknown as Parameters< - typeof excalidrawApi.updateScene - >[0]["elements"], - appState: input.appState as Parameters< - typeof excalidrawApi.updateScene - >[0]["appState"], - }); - - lastSceneFingerprintRef.current = createSceneFingerprint({ - elements: input.elements, - files: input.files, - }); - const nonDeleted = input.elements.filter( - (element) => element.isDeleted !== true - ).length; - setLiveNonDeletedCount(nonDeleted); - knownVersionRef.current = input.version; - appliedVersionRef.current = input.version; - isLocallyDirtyRef.current = false; - - requestAnimationFrame(() => { - suppressOnChangeRef.current = false; - }); - }, - [excalidrawApi] - ); - - const handleReady = useCallback( - (readyApi: ExcalidrawImperativeAPI) => { - setExcalidrawApi(readyApi); - - if (session?.latestScene && !initialLoadAppliedRef.current) { - initialLoadAppliedRef.current = true; - applySceneToCanvas({ - elements: session.latestScene.elements as readonly Record< - string, - unknown - >[], - appState: session.latestScene.appState as Record, - files: session.latestScene.files as StoredSceneFiles | undefined, - version: session.latestSceneVersion, - }); - } else { - knownVersionRef.current = session?.latestSceneVersion ?? 0; - appliedVersionRef.current = session?.latestSceneVersion ?? 0; - requestAnimationFrame(() => { - suppressOnChangeRef.current = false; - }); - } - }, - [applySceneToCanvas, session?.latestScene, session?.latestSceneVersion] - ); - - useEffect(() => { - if (!(session?.latestScene && excalidrawApi)) { - return; - } - - const version = session.latestSceneVersion; - if (appliedVersionRef.current === version) { - return; - } - - // If remote version advanced while local edits are unsaved, enter conflict - // mode instead of silently replacing local work. - if (isLocallyDirtyRef.current && version > knownVersionRef.current) { - if (autosaveTimeoutRef.current) { - clearTimeout(autosaveTimeoutRef.current); - autosaveTimeoutRef.current = null; - } - - pendingSceneRef.current = { - elements: excalidrawApi - .getSceneElements() - .map((element) => ({ ...element })) as readonly Record< - string, - unknown - >[], - appState: sanitizeAppState( - excalidrawApi.getAppState() as Record - ), - files: normalizeSceneFiles(excalidrawApi.getFiles()), - }; - knownVersionRef.current = version; - setSaveState({ - status: "conflict", - serverVersion: version, - }); - return; - } - - applySceneToCanvas({ - elements: session.latestScene.elements as readonly Record< - string, - unknown - >[], - appState: session.latestScene.appState as Record, - files: session.latestScene.files as StoredSceneFiles | undefined, - version, - }); - }, [ - applySceneToCanvas, - excalidrawApi, - session?.latestScene, - session?.latestSceneVersion, - ]); - - const handleConflictReload = useCallback(async () => { - if (!session?.latestScene) { - return; - } - - applySceneToCanvas({ - elements: session.latestScene.elements as readonly Record< - string, - unknown - >[], - appState: session.latestScene.appState as Record, - files: session.latestScene.files as StoredSceneFiles | undefined, - version: session.latestSceneVersion, - }); - - pendingSceneRef.current = null; - isLocallyDirtyRef.current = false; - setSaveState({ status: "saved", savedAt: Date.now() }); - }, [applySceneToCanvas, session?.latestScene, session?.latestSceneVersion]); - - const handleConflictOverwrite = useCallback(async () => { - if (!pendingSceneRef.current || saveState.status !== "conflict") { - return; - } - - await saveScene( - pendingSceneRef.current.elements, - pendingSceneRef.current.appState, - pendingSceneRef.current.files, - saveState.serverVersion - ); - }, [saveScene, saveState]); - - const handlePrompt = useCallback( - async (prompt: string, promptMessageId: string) => { - if (!(sessionId && prompt.trim().length > 0) || isProcessing) { - return; - } - - setOptimisticMessages((previous) => [ - ...previous, - createOptimisticUserMessage({ content: prompt, promptMessageId }), - ]); - setOptimisticRunState({ - promptMessageId, - status: "sending", - stopRequested: false, - }); - - try { - await enqueuePrompt({ - sessionId, - prompt, - promptMessageId, - traceId: crypto.randomUUID(), - }); - } catch (error) { - setOptimisticMessages((previous) => - previous.filter( - (message) => message.promptMessageId !== promptMessageId - ) - ); - setOptimisticRunState((previous) => { - if (!previous) { - return null; - } - if (previous.promptMessageId !== promptMessageId) { - return previous; - } - return null; - }); - const message = - error instanceof Error ? error.message : "Failed to send prompt"; - toast.error(message); - } - }, - [enqueuePrompt, isProcessing, sessionId] - ); - - const handleStopPrompt = useCallback( - async (promptMessageId: string) => { - if (!sessionId) { - return; - } - - try { - let attempts = 0; - let requested: { - status: "requested"; - runStatus: - | "sending" - | "running" - | "applying" - | "persisted" - | "stopped" - | "error"; - promptMessageId: string; - } | null = null; - - while (attempts <= STOP_RETRY_DELAYS_MS.length) { - const result = await stopPrompt({ - sessionId, - promptMessageId, - }); - - if (result.status === "requested") { - requested = result; - break; - } - - if (attempts === STOP_RETRY_DELAYS_MS.length) { - break; - } - - await delay(STOP_RETRY_DELAYS_MS[attempts] ?? 0); - attempts += 1; - } - - if (requested) { - const resolvedPromptMessageId = requested.promptMessageId; - setOptimisticRunState((previous) => { - if (!previous) { - return previous; - } - if ( - previous.promptMessageId !== promptMessageId && - previous.promptMessageId !== resolvedPromptMessageId - ) { - return previous; - } - return { - ...previous, - promptMessageId: resolvedPromptMessageId, - status: requested.runStatus as RunStatus, - stopRequested: true, - }; - }); - } else { - toast.error("Prompt is no longer running."); - } - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to stop prompt"; - toast.error(message); - } - }, - [sessionId, stopPrompt] - ); - - const handleCreateNew = useCallback(async () => { - if (isCreating) { - return; - } - setIsCreating(true); - try { - const { sessionId: newId } = await createSession({ source: "sketchi" }); - router.push(`/diagrams/${newId}` as never); - } catch { - // user can retry - } finally { - setIsCreating(false); - } - }, [createSession, isCreating, router]); - - const threadMessages = useMemo(() => { - if (!thread) { - return [] as ThreadMessage[]; - } - return thread.messages as ThreadMessage[]; - }, [thread]); - - const combinedMessages = useMemo(() => { - const merged = [...optimisticMessages, ...threadMessages]; - merged.sort((left, right) => left.createdAt - right.createdAt); - return merged; - }, [optimisticMessages, threadMessages]); - - if (authLoading || session === undefined) { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); - } - - if (session === null) { - return ( -
-
-

Session not found

-

- This diagram session doesn't exist or may have been removed. -

-
- -
- ); - } - - const allElements = (session.latestScene?.elements ?? []) as readonly Record< - string, - unknown - >[]; - const elementCount = allElements.length; - - return ( -
-
- - {sessionId.slice(0, 8)}... - -
- - v{session.latestSceneVersion} - -
- - {elementCount} {elementCount === 1 ? "element" : "elements"} - -
- -
- - saveScene(elements, appState, files) - } - suppressOnChangeRef={suppressOnChangeRef} - /> -
-
- - {saveState.status === "conflict" && ( -
- - - Another tab saved a newer version. Your unsaved changes may - conflict. - -
- - -
-
- )} - -
-
- [], - appState: session.latestScene.appState as Record< - string, - unknown - >, - files: session.latestScene.files as - | StoredSceneFiles - | undefined, - } - : null - } - onChange={handleChange} - onReady={handleReady} - suppressOnChangeRef={suppressOnChangeRef} - /> -
- - -
-
- ); -} - -function SaveStatus({ saveState }: { saveState: SaveState }) { - const formatTime = (timestamp: number) => { - const date = new Date(timestamp); - return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - }; - - switch (saveState.status) { - case "idle": - return ( - - Unsaved - - ); - case "saving": - return ( - - - Saving... - - ); - case "saved": - return ( - - - Saved {formatTime(saveState.savedAt)} - - ); - case "conflict": - return ( - - - Conflict - - ); - case "error": - return ( - - {saveState.message} - - ); - default: - return null; - } -} diff --git a/apps/web/src/app/diagrams/page.tsx b/apps/web/src/app/diagrams/page.tsx deleted file mode 100644 index fa106cb0..00000000 --- a/apps/web/src/app/diagrams/page.tsx +++ /dev/null @@ -1,362 +0,0 @@ -"use client"; - -import { api } from "@sketchi/backend/convex/_generated/api"; -import { useConvexAuth, useMutation, useQuery } from "convex/react"; -import { ArrowRight, Clock, Trash2 } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; - -import { - type DiagramListCard, - DiagramListItem, - type DiagramListSource, -} from "@/components/diagram-studio/diagram-list-item"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Label } from "@/components/ui/label"; -import { - addDiagramRecent, - clearDiagramRecents, - type DiagramRecent, - readDiagramRecents, - removeDiagramRecent, -} from "@/lib/diagram-recents"; - -type SessionSource = "opencode" | "sketchi"; - -interface SessionPreview { - appState: Record; - elements: Record[]; - files?: Record; -} - -interface CloudDiagram { - createdAt: number; - diagramType: string | null; - firstPrompt: string | null; - hasRenderableContent: boolean; - hasScene: boolean; - lastPrompt: string | null; - latestSceneVersion: number; - previewScene: SessionPreview | null; - sessionId: string; - source: SessionSource; - title: string; - updatedAt: number; -} - -function asCloudDiagramArray(value: unknown): CloudDiagram[] { - if (!Array.isArray(value)) { - return []; - } - - return value.filter((item): item is CloudDiagram => { - if (!(item && typeof item === "object")) { - return false; - } - - const candidate = item as CloudDiagram; - return ( - typeof candidate.sessionId === "string" && - typeof candidate.title === "string" && - (candidate.source === "sketchi" || candidate.source === "opencode") && - typeof candidate.createdAt === "number" && - typeof candidate.updatedAt === "number" && - typeof candidate.latestSceneVersion === "number" && - typeof candidate.hasScene === "boolean" && - typeof candidate.hasRenderableContent === "boolean" - ); - }); -} - -function isEmptyCard(item: DiagramListCard): boolean { - if (item.localOnly) { - return false; - } - if (!item.hasScene) { - return true; - } - return item.hasRenderableContent === false; -} - -function mergeCards(input: { - cloudDiagrams: CloudDiagram[]; - localRecents: DiagramRecent[]; -}): DiagramListCard[] { - const localBySession = new Map( - input.localRecents.map((recent) => [recent.sessionId, recent.visitedAt]) - ); - - const merged = new Map(); - - for (const session of input.cloudDiagrams) { - merged.set(session.sessionId, { - context: session.lastPrompt ?? session.firstPrompt, - createdAt: session.createdAt, - diagramType: session.diagramType, - hasRenderableContent: session.hasRenderableContent, - hasScene: session.hasScene, - latestSceneVersion: session.latestSceneVersion, - localOnly: false, - previewScene: session.previewScene, - sessionId: session.sessionId, - source: session.source as DiagramListSource, - title: session.title, - updatedAt: session.updatedAt, - visitedAt: localBySession.get(session.sessionId) ?? null, - }); - } - - for (const local of input.localRecents) { - if (merged.has(local.sessionId)) { - continue; - } - - merged.set(local.sessionId, { - context: null, - createdAt: null, - diagramType: null, - hasRenderableContent: null, - hasScene: false, - latestSceneVersion: null, - localOnly: true, - previewScene: null, - sessionId: local.sessionId, - source: "local", - title: "Local recent", - updatedAt: null, - visitedAt: local.visitedAt, - }); - } - - return Array.from(merged.values()).sort((left, right) => { - const leftSort = Math.max(left.updatedAt ?? 0, left.visitedAt ?? 0); - const rightSort = Math.max(right.updatedAt ?? 0, right.visitedAt ?? 0); - return rightSort - leftSort; - }); -} - -export default function DiagramsPage() { - const router = useRouter(); - const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth(); - const createSession = useMutation(api.diagramSessions.create); - const renameSession = useMutation(api.diagramSessions.rename); - - const sessionsRaw = useQuery( - api.diagramSessions.listMine, - isAuthLoading || !isAuthenticated - ? "skip" - : { - limit: 80, - previewCount: 3, - } - ); - - const [editingSessionId, setEditingSessionId] = useState(null); - const [editingTitle, setEditingTitle] = useState(""); - const [isCreating, setIsCreating] = useState(false); - const [hideEmpty, setHideEmpty] = useState(true); - const [isSavingTitle, setIsSavingTitle] = useState(false); - const [localRecents, setLocalRecents] = useState([]); - - useEffect(() => { - setLocalRecents(readDiagramRecents()); - }, []); - - const cloudDiagrams = useMemo( - () => asCloudDiagramArray(sessionsRaw ?? []), - [sessionsRaw] - ); - - const cards = useMemo( - () => mergeCards({ cloudDiagrams, localRecents }), - [cloudDiagrams, localRecents] - ); - const visibleCards = useMemo( - () => (hideEmpty ? cards.filter((card) => !isEmptyCard(card)) : cards), - [cards, hideEmpty] - ); - const hiddenEmptyCount = cards.length - visibleCards.length; - const onlyEmptyHidden = - hideEmpty && cards.length > 0 && visibleCards.length < 1; - - const handleCreate = useCallback(async () => { - if (isCreating) { - return; - } - - setIsCreating(true); - try { - const { sessionId } = await createSession({ source: "sketchi" }); - addDiagramRecent(sessionId); - router.push(`/diagrams/${sessionId}` as never); - } catch (error) { - const message = - error instanceof Error - ? error.message - : "Failed to create diagram session."; - toast.error(message); - } finally { - setIsCreating(false); - } - }, [createSession, isCreating, router]); - - const handleSaveRename = useCallback( - async (sessionId: string) => { - const nextTitle = editingTitle.trim(); - if (!nextTitle) { - toast.error("Title cannot be empty."); - return; - } - - setIsSavingTitle(true); - try { - await renameSession({ sessionId, title: nextTitle }); - setEditingSessionId(null); - setEditingTitle(""); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to rename diagram."; - toast.error(message); - } finally { - setIsSavingTitle(false); - } - }, - [editingTitle, renameSession] - ); - - const handleStartRename = useCallback((item: DiagramListCard) => { - if (item.localOnly) { - return; - } - - setEditingSessionId(item.sessionId); - setEditingTitle(item.title); - }, []); - - const handleCancelRename = useCallback(() => { - setEditingSessionId(null); - setEditingTitle(""); - }, []); - - const handleOpen = useCallback((sessionId: string) => { - addDiagramRecent(sessionId); - }, []); - - const handleClearLocalRecents = useCallback(() => { - clearDiagramRecents(); - setLocalRecents([]); - }, []); - - const handleRemoveLocalRecent = useCallback((sessionId: string) => { - const next = removeDiagramRecent(sessionId); - setLocalRecents(next); - }, []); - - return ( -
-
-
-

- Diagrams -

-

- Jump back into any diagram. -

-
- - -
- -
-
-

All diagrams

-
- - - {localRecents.length > 0 ? ( - - ) : null} -
-
- - {visibleCards.length > 0 ? ( -
    - {visibleCards.map((item) => ( - - ))} -
- ) : ( -
- -

- {onlyEmptyHidden ? "No non-empty diagrams." : "No diagrams yet."} -

- {onlyEmptyHidden ? ( - - ) : null} -
- )} -
-
- ); -} diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico deleted file mode 100644 index 0017bb7f..00000000 Binary files a/apps/web/src/app/favicon.ico and /dev/null differ diff --git a/apps/web/src/app/global-error.tsx b/apps/web/src/app/global-error.tsx deleted file mode 100644 index 989fd975..00000000 --- a/apps/web/src/app/global-error.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { captureException } from "@sentry/nextjs"; -import { useEffect } from "react"; - -export default function GlobalError({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - useEffect(() => { - captureException(error); - }, [error]); - - return ( - - -
-

Something went wrong

-

- An unexpected error occurred. Try again, or refresh the page. -

- -
- - - ); -} diff --git a/apps/web/src/app/icon.svg b/apps/web/src/app/icon.svg deleted file mode 100644 index 1c16ab65..00000000 --- a/apps/web/src/app/icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx deleted file mode 100644 index d2eb4e83..00000000 --- a/apps/web/src/app/layout.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { Analytics } from "@vercel/analytics/react"; -import { AuthKitProvider } from "@workos-inc/authkit-nextjs/components"; -import type { Metadata } from "next"; - -import { Caveat, Geist, Geist_Mono } from "next/font/google"; - -import "../index.css"; -import Header from "@/components/header"; -import Providers from "@/components/providers"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - -const caveat = Caveat({ - variable: "--font-caveat", - subsets: ["latin"], -}); - -const siteUrl = "https://sketchi.app"; -const ogImageLight = "/og-image.png"; -const ogImageDark = "/og-image-dark.png"; -const ogImageWidth = 2400; -const ogImageHeight = 1260; -const description = - "Sketchi is a diagram and icon library toolkit that transforms SVGs into hand-drawn Excalidraw assets. Build icon libraries, generate AI-powered diagrams, and export production-ready .excalidrawlib files."; - -export const metadata: Metadata = { - title: "Sketchi", - description, - metadataBase: new URL(siteUrl), - openGraph: { - title: "Sketchi", - description, - url: siteUrl, - siteName: "Sketchi", - type: "website", - images: [ - { - url: ogImageLight, - width: ogImageWidth, - height: ogImageHeight, - alt: "Sketchi home page (light mode)", - }, - { - url: ogImageDark, - width: ogImageWidth, - height: ogImageHeight, - alt: "Sketchi home page (dark mode)", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "Sketchi", - description, - images: [ogImageLight], - }, -}; - -const safeSerialize = (value: string) => - JSON.stringify(value).replace(/) { - const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL ?? ""; - const convexScript = { - __html: `window.__SKETCHI_CONVEX_URL=${safeSerialize(convexUrl)};`, - }; - return ( - - - {convexUrl ? ( - - - -
Loading...
- - - -`; - -async function renderElementsToPng( - elements: Record[] -): Promise { - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext(); - const page = await context.newPage(); - - try { - await page.goto("about:blank"); - await page.evaluate((html) => { - document.open(); - document.write(html); - document.close(); - }, EXPORT_HARNESS_HTML); - - await page.waitForFunction("window.exportReady === true", { - timeout: RENDER_TIMEOUT_MS, - }); - - const base64Png = (await page.evaluate( - async ({ elements }) => { - // biome-ignore lint/suspicious/noExplicitAny: injected by harness - return await (window as any).exportPng(elements, { - scale: 2, - padding: 20, - background: true, - backgroundColor: "#ffffff", - }); - }, - { elements } - )) as string; - - return Buffer.from(base64Png, "base64"); - } finally { - await context.close(); - await browser.close(); - } -} - -async function createV1ShareLinkFromElements(payload: { - elements: unknown[]; - appState: Record; -}): Promise { - const encodedPayload = new TextEncoder().encode(JSON.stringify(payload)); - - const key = await crypto.subtle.generateKey( - { name: "AES-GCM", length: AES_GCM_KEY_LENGTH }, - true, - ["encrypt", "decrypt"] - ); - - const iv = crypto.getRandomValues(new Uint8Array(IV_BYTE_LENGTH)); - const encrypted = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - key, - encodedPayload - ); - - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv); - combined.set(new Uint8Array(encrypted), iv.length); - - const response = await fetch(EXCALIDRAW_POST_URL, { - method: "POST", - body: combined, - }); - - if (!response.ok) { - throw new Error( - `Upload failed: ${response.status} ${await response.text()}` - ); - } - - const { id } = (await response.json()) as { id: string }; - const jwk = await crypto.subtle.exportKey("jwk", key); - if (!jwk.k) { - throw new Error("Failed to export encryption key"); - } - - return `https://excalidraw.com/#json=${id},${jwk.k}`; -} - -async function main() { - console.log("MCP V2 Share Link Test"); - console.log("=".repeat(50)); - - const errors: string[] = []; - let status: "passed" | "failed" = "passed"; - - try { - // Step 1: Read V2 share link from fixture - console.log("\n[1/6] Reading V2 share link from fixture..."); - const fixtureContent = await readFile(FIXTURE_PATH, "utf-8"); - const v2ShareUrl = fixtureContent.trim(); - - if (!v2ShareUrl.includes("#json=")) { - throw new Error("Invalid fixture: expected Excalidraw share URL format"); - } - console.log(` Share URL: ${v2ShareUrl.slice(0, 80)}...`); - - // Step 2: Parse the V2 share link - console.log("\n[2/6] Parsing V2 share link..."); - let parsed: Awaited>; - try { - parsed = await parseExcalidrawShareLink(v2ShareUrl); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if ( - message.includes("OperationError") || - message.includes("Decryption failed") - ) { - throw new Error(`V2 parsing failed with crypto error: ${message}`); - } - throw error; - } - - // Step 3: Verify parsed elements - console.log("\n[3/6] Verifying parsed elements..."); - if (!(parsed.elements && Array.isArray(parsed.elements))) { - throw new Error("Parsed result missing elements array"); - } - if (parsed.elements.length === 0) { - throw new Error("Parsed elements array is empty"); - } - console.log(` Elements count: ${parsed.elements.length}`); - console.log(` Has appState: ${parsed.appState != null}`); - - // Step 4: Render V2 to PNG - console.log("\n[4/6] Rendering V2 elements to PNG..."); - const pngV2 = await renderElementsToPng( - parsed.elements as Record[] - ); - - if (pngV2.length === 0) { - throw new Error("PNG render produced empty buffer"); - } - - // Ensure output directory exists - await mkdir(OUTPUT_DIR, { recursive: true }); - await writeFile(OUTPUT_PNG_V2, pngV2); - console.log(` PNG size: ${pngV2.length} bytes`); - console.log(` PNG path: ${OUTPUT_PNG_V2}`); - - // Step 5: Create V1 share link from parsed payload - console.log("\n[5/6] Creating a V1 share link from parsed elements..."); - const v1ShareUrl = await createV1ShareLinkFromElements({ - elements: parsed.elements, - appState: parsed.appState, - }); - console.log(` Share URL: ${v1ShareUrl.slice(0, 80)}...`); - - // Step 6: Parse V1 share link and render to PNG - console.log("\n[6/6] Parsing V1 share link and rendering to PNG..."); - const parsedV1 = await parseExcalidrawShareLink(v1ShareUrl); - if (!Array.isArray(parsedV1.elements) || parsedV1.elements.length === 0) { - throw new Error("V1 parsed elements array is empty"); - } - - const pngV1 = await renderElementsToPng( - parsedV1.elements as Record[] - ); - if (pngV1.length === 0) { - throw new Error("V1 PNG render produced empty buffer"); - } - await writeFile(OUTPUT_PNG_V1, pngV1); - console.log(` PNG size: ${pngV1.length} bytes`); - console.log(` PNG path: ${OUTPUT_PNG_V1}`); - - console.log(`\n${"=".repeat(50)}`); - console.log( - "TEST PASSED: V2 and V1 share links parsed and rendered successfully" - ); - } catch (error) { - status = "failed"; - const message = error instanceof Error ? error.message : String(error); - errors.push(message); - console.error(`\n${"=".repeat(50)}`); - console.error(`TEST FAILED: ${message}`); - process.exitCode = 1; - } - - // Summary - console.log("\n--- Summary ---"); - console.log(`Status: ${status}`); - if (errors.length > 0) { - console.log(`Errors: ${errors.join(", ")}`); - } -} - -await main(); diff --git a/tests/e2e/src/scenarios/unauthenticated/opencode-web-continuity.ts b/tests/e2e/src/scenarios/unauthenticated/opencode-web-continuity.ts deleted file mode 100644 index 14904824..00000000 --- a/tests/e2e/src/scenarios/unauthenticated/opencode-web-continuity.ts +++ /dev/null @@ -1,1141 +0,0 @@ -/* -Scenario: OpenCode (CLI device OAuth) <-> Web continuity - -Intent: Execute the real CLI-style device OAuth flow, run thread-backed diagram - generation via API, then resume and continue the same session in web, - and finally continue again via API to prove bidirectional continuity. - -Smoke coverage: -- Browser handoff to WorkOS device verification URL. -- Signed-in web navigation to existing session URL. - -Assertion coverage: -- Device flow mints a bearer token via /api/auth/device/* (no mocked auth). -- /api/diagrams/thread-run creates durable session/thread and returns persisted run. -- Web loads the same session with thread/tool history. -- Web prompt persists and API can continue same session/thread afterward. -- OCC conflict path on /api/diagrams/session-seed is explicit and recoverable. -- Trace IDs are echoed by API headers. -*/ - -import { ensureSignedInForDiagrams } from "../../runner/auth"; -import { loadConfig } from "../../runner/config"; -import { - captureScreenshot, - createStagehand, - getActivePage, - shutdown, -} from "../../runner/stagehand"; -import { writeScenarioSummary } from "../../runner/summary"; -import { - ensureDesktopViewport, - finalizeScenario, - resetBrowserState, - resolveUrl, -} from "../../runner/utils"; -import { sleep, waitForCondition } from "../../runner/wait"; - -type PageLike = Awaited>; - -const DEVICE_FLOW_TIMEOUT_MS = 160_000; -const THREAD_RUN_TIMEOUT_MS = 180_000; -const SESSION_VERSION_REGEX = /\d+/; - -interface DeviceStartResponse { - deviceCode: string; - expiresIn: number; - interval: number; - userCode: string; - verificationUrl: string; -} - -type DeviceTokenResponse = - | { - status: "authorization_pending"; - interval: number; - } - | { - status: "slow_down"; - interval: number; - } - | { - status: "success"; - accessToken: string; - accessTokenExpiresAt?: number; - } - | { - status: "expired_token" | "invalid_grant"; - }; - -interface ThreadRunResponse { - appState?: Record; - assistantMessage: string | null; - elapsedMs: number; - elements?: unknown[]; - latestSceneVersion: number | null; - promptMessageId: string; - reasoningSummary: string | null; - runError: string | null; - runStatus: - | "sending" - | "running" - | "applying" - | "persisted" - | "stopped" - | "error"; - sessionId: string; - shareLink?: { - url: string; - shareId: string; - encryptionKey: string; - }; - status: "persisted" | "error" | "stopped" | "timeout"; - threadId: string | null; - traceId: string; -} - -interface SessionSeedResponse { - latestSceneVersion: number; - savedAt?: number; - sessionId: string; - status: "success" | "conflict"; - threadId: string | null; - traceId: string; -} - -function toTraceId(suffix: string): string { - return `00000000-0000-4000-8000-${suffix.padStart(12, "0")}`; -} - -function resolveAuthCredentials(): { email: string; password: string } { - const email = - process.env.SKETCHI_E2E_EMAIL?.trim() ?? - process.env.E2E_WORKOS_EMAIL?.trim(); - const password = - process.env.SKETCHI_E2E_PASSWORD?.trim() ?? - process.env.E2E_WORKOS_PASSWORD?.trim(); - - if (!(email && password)) { - throw new Error( - "Missing SKETCHI_E2E_EMAIL/SKETCHI_E2E_PASSWORD for continuity E2E." - ); - } - - return { email, password }; -} - -function createApiHeaders(input: { - accessToken?: string; - bypassSecret?: string; - traceId: string; -}): Record { - const headers: Record = { - "content-type": "application/json", - "x-trace-id": input.traceId, - }; - if (input.bypassSecret) { - headers["x-vercel-protection-bypass"] = input.bypassSecret; - } - if (input.accessToken) { - headers.authorization = `Bearer ${input.accessToken}`; - } - return headers; -} - -async function parseJsonResponse(response: Response): Promise { - const text = await response.text(); - if (!text) { - throw new Error("Expected JSON response body but got empty payload."); - } - try { - return JSON.parse(text) as T; - } catch { - throw new Error(`Invalid JSON response: ${text.slice(0, 240)}`); - } -} - -function assertTraceHeader( - response: Response, - expectedTraceId: string, - route: string -) { - const traceId = response.headers.get("x-trace-id"); - if (traceId !== expectedTraceId) { - throw new Error( - `${route} returned x-trace-id "${traceId ?? "missing"}" (expected "${expectedTraceId}").` - ); - } -} - -async function clickIfVisible( - page: PageLike, - selector: string -): Promise { - try { - const target = page.locator(selector).first(); - if (await target.isVisible({ timeout: 1500 })) { - await target.click(); - return true; - } - } catch { - return false; - } - return false; -} - -async function clickFirstVisible( - page: PageLike, - selectors: string[] -): Promise { - for (const selector of selectors) { - if (await clickIfVisible(page, selector)) { - return true; - } - } - return false; -} - -async function clickApprovalAction(page: PageLike): Promise { - const clickedByLabel = await clickFirstVisible(page, [ - 'button:has-text("Allow")', - 'button:has-text("Authorize")', - 'button:has-text("Approve")', - 'button:has-text("Continue")', - 'button:has-text("Confirm")', - 'button:has-text("Bevestig")', - 'button:has-text("Accept")', - ]); - if (clickedByLabel) { - return true; - } - - try { - const submitButtons = page.locator('button[type="submit"]'); - const count = await submitButtons.count(); - if (count >= 2) { - await submitButtons.nth(count - 1).click(); - return true; - } - } catch { - return false; - } - return false; -} - -async function submitDeviceApproval(page: PageLike): Promise { - for (let attempt = 0; attempt < 8; attempt += 1) { - const currentUrl = page.url(); - if (currentUrl.includes("/device/denied")) { - throw new Error("WorkOS device authorization was denied."); - } - - const approved = await page.evaluate(() => { - const text = (document.body?.innerText ?? "").toLowerCase(); - return ( - text.includes("you can close") || - text.includes("device authorized") || - text.includes("successfully approved") || - text.includes("toestel geaktiveer") - ); - }); - if (approved || currentUrl.includes("/device/approved")) { - return; - } - - await clickApprovalAction(page); - await sleep(900); - } -} - -type DeviceAuthorizationState = - | "approved" - | "approval" - | "form" - | "gate" - | "unknown"; - -function getDeviceAuthorizationState( - page: PageLike -): Promise { - return page.evaluate(() => { - const bodyText = (document.body?.innerText ?? "").toLowerCase(); - const approved = - bodyText.includes("you can close") || - bodyText.includes("device authorized") || - bodyText.includes("successfully approved") || - bodyText.includes("toestel geaktiveer") || - window.location.pathname.includes("/device/approved") || - window.location.pathname.includes("/device/success"); - if (approved) { - return "approved"; - } - - const hasForm = Boolean( - document.querySelector('input[type="email"], input[type="password"]') - ); - if (hasForm) { - return "form"; - } - - const hasApprovalButton = Array.from( - document.querySelectorAll("button, a, input[type='submit']") - ).some((node) => { - const text = (node.textContent ?? "").toLowerCase(); - return ( - text.includes("allow") || - text.includes("authorize") || - text.includes("approve") || - text.includes("continue") || - text.includes("confirm") || - text.includes("bevestig") || - text.includes("accept") - ); - }); - - const hasDeviceCodeField = Boolean( - document.querySelector( - 'input[name*="code" i], input[id*="code" i], input[autocomplete="one-time-code"]' - ) - ); - - if ( - hasApprovalButton || - hasDeviceCodeField || - bodyText.includes("authorize opencode device") || - bodyText.includes("approve this device code") - ) { - return "approval"; - } - - const signInGateVisible = - bodyText.includes("sign in first") || - bodyText.includes("sign in to continue") || - bodyText.includes("continue to sign in"); - if (signInGateVisible) { - return "gate"; - } - - return "unknown"; - }); -} - -async function submitDeviceCredentials(params: { - email: string; - page: PageLike; - password: string; -}): Promise { - const { page } = params; - const hasEmailField = await page.evaluate(() => - Boolean(document.querySelector('input[type="email"]')) - ); - if (hasEmailField) { - await page.locator('input[type="email"]').first().fill(params.email); - if (page.keyboard) { - await page.keyboard.press("Enter"); - } else { - await clickIfVisible(page, 'button[type="submit"]'); - } - } - - const passwordVisible = await waitForCondition( - () => - page.evaluate(() => - Boolean(document.querySelector('input[type="password"]')) - ), - { timeoutMs: 12_000, label: "device-flow-password-field-enter" } - ); - if (!passwordVisible) { - await clickFirstVisible(page, [ - 'button:has-text("Continue")', - 'button[type="submit"]', - ]); - } - - const passwordVisibleAfterSubmit = await waitForCondition( - () => - page.evaluate(() => - Boolean(document.querySelector('input[type="password"]')) - ), - { timeoutMs: 18_000, label: "device-flow-password-field-submit" } - ); - if (!passwordVisibleAfterSubmit) { - throw new Error("WorkOS password field did not appear."); - } - - await page.locator('input[type="password"]').first().fill(params.password); - if (page.keyboard) { - await page.keyboard.press("Enter"); - } else { - await clickIfVisible(page, 'button[type="submit"]'); - } -} - -async function clickDeviceSignInGate(page: PageLike): Promise { - await clickFirstVisible(page, [ - 'button:has-text("Sign in to continue")', - 'a:has-text("Sign in to continue")', - 'a:has-text("Continue to sign in")', - ]); - await sleep(800); -} - -async function tryApproveDevice( - page: PageLike, - userCode: string -): Promise { - await fillDeviceUserCodeIfPresent(page, userCode); - await submitDeviceApproval(page); - await sleep(1000); - return (await getDeviceAuthorizationState(page)) === "approved"; -} - -async function fillDeviceUserCodeIfPresent( - page: PageLike, - userCode: string -): Promise { - const codeFieldFound = await page.evaluate(() => { - return Boolean( - document.querySelector( - 'input[name*="code" i], input[id*="code" i], input[autocomplete="one-time-code"]' - ) - ); - }); - if (!codeFieldFound) { - return false; - } - - const filled = await page.evaluate((value) => { - const candidates = Array.from( - document.querySelectorAll( - 'input[name*="code" i], input[id*="code" i], input[autocomplete="one-time-code"]' - ) - ); - for (const input of candidates) { - if (input.disabled) { - continue; - } - input.focus(); - input.value = value; - input.dispatchEvent(new Event("input", { bubbles: true })); - input.dispatchEvent(new Event("change", { bubbles: true })); - return true; - } - return false; - }, userCode); - - if (!filled) { - return false; - } - - if (page.keyboard) { - await page.keyboard.press("Enter"); - } else { - await clickFirstVisible(page, [ - 'button:has-text("Continue")', - 'button:has-text("Allow")', - 'button:has-text("Authorize")', - ]); - } - - return true; -} - -async function completeDeviceAuthorization(params: { - page: PageLike; - userCode: string; - verificationUrl: string; - email: string; - password: string; -}) { - const { page } = params; - - await page.goto(params.verificationUrl, { waitUntil: "domcontentloaded" }); - await sleep(1000); - - let attemptedCredentialSubmit = false; - for (let attempt = 0; attempt < 30; attempt += 1) { - const state = await getDeviceAuthorizationState(page); - - switch (state) { - case "approved": - return; - case "gate": - await clickDeviceSignInGate(page); - continue; - case "form": - await submitDeviceCredentials({ - page, - email: params.email, - password: params.password, - }); - attemptedCredentialSubmit = true; - await sleep(1200); - continue; - case "approval": - if (await tryApproveDevice(page, params.userCode)) { - return; - } - break; - default: - break; - } - - await sleep(attemptedCredentialSubmit ? 1100 : 700); - } - - throw new Error( - `Device authorization did not reach approved state (url=${page.url()}).` - ); -} - -async function startDeviceFlow(params: { - baseUrl: string; - bypassSecret?: string; - traceId: string; -}): Promise { - const response = await fetch( - resolveUrl(params.baseUrl, "/api/auth/device/start"), - { - method: "POST", - headers: createApiHeaders({ - traceId: params.traceId, - bypassSecret: params.bypassSecret, - }), - body: "{}", - } - ); - assertTraceHeader(response, params.traceId, "/api/auth/device/start"); - const payload = await parseJsonResponse< - DeviceStartResponse | { error: string } - >(response); - if (!response.ok) { - throw new Error( - `Device flow start failed (${response.status}): ${JSON.stringify(payload)}` - ); - } - - if ( - !( - typeof payload.deviceCode === "string" && - typeof payload.userCode === "string" && - typeof payload.verificationUrl === "string" && - typeof payload.interval === "number" && - typeof payload.expiresIn === "number" - ) - ) { - throw new Error( - `Unexpected device flow start payload: ${JSON.stringify(payload)}` - ); - } - - return payload; -} - -async function pollDeviceFlowToken(params: { - baseUrl: string; - bypassSecret?: string; - deviceCode: string; - intervalSeconds: number; - expiresInSeconds: number; - traceId: string; -}): Promise { - const startedAt = Date.now(); - const timeoutMs = Math.max( - DEVICE_FLOW_TIMEOUT_MS, - Math.max(1, params.expiresInSeconds) * 1000 - ); - let intervalMs = Math.max(1000, Math.max(1, params.intervalSeconds) * 1000); - - while (Date.now() - startedAt < timeoutMs) { - await sleep(intervalMs + 750); - - const response = await fetch( - resolveUrl(params.baseUrl, "/api/auth/device/token"), - { - method: "POST", - headers: createApiHeaders({ - traceId: params.traceId, - bypassSecret: params.bypassSecret, - }), - body: JSON.stringify({ - deviceCode: params.deviceCode, - }), - } - ); - assertTraceHeader(response, params.traceId, "/api/auth/device/token"); - const payload = await parseJsonResponse< - DeviceTokenResponse | { error: string } - >(response); - - if (!(response.ok && "status" in payload)) { - throw new Error( - `Device flow token poll failed (${response.status}): ${JSON.stringify(payload)}` - ); - } - - if (payload.status === "success") { - return payload.accessToken; - } - - if (payload.status === "authorization_pending") { - intervalMs = Math.max(intervalMs, Math.max(1, payload.interval) * 1000); - continue; - } - - if (payload.status === "slow_down") { - intervalMs = Math.max( - intervalMs + 5000, - Math.max(1, payload.interval) * 1000 - ); - continue; - } - - throw new Error(`Device flow ended with status "${payload.status}".`); - } - - throw new Error("Device flow polling timed out."); -} - -async function runThreadPrompt(params: { - accessToken: string; - baseUrl: string; - bypassSecret?: string; - prompt: string; - promptMessageId: string; - sessionId?: string; - traceId: string; -}): Promise { - const response = await fetch( - resolveUrl(params.baseUrl, "/api/diagrams/thread-run"), - { - method: "POST", - headers: createApiHeaders({ - accessToken: params.accessToken, - bypassSecret: params.bypassSecret, - traceId: params.traceId, - }), - body: JSON.stringify({ - prompt: params.prompt, - sessionId: params.sessionId, - promptMessageId: params.promptMessageId, - timeoutMs: THREAD_RUN_TIMEOUT_MS, - pollIntervalMs: 700, - traceId: params.traceId, - }), - } - ); - assertTraceHeader(response, params.traceId, "/api/diagrams/thread-run"); - - const payload = await parseJsonResponse< - ThreadRunResponse | { error: string } - >(response); - if (!response.ok) { - throw new Error( - `thread-run failed (${response.status}): ${JSON.stringify(payload)}` - ); - } - - if (!("status" in payload)) { - throw new Error( - `Unexpected thread-run payload: ${JSON.stringify(payload)}` - ); - } - - if (payload.traceId !== params.traceId) { - throw new Error( - `thread-run trace mismatch: payload=${payload.traceId} expected=${params.traceId}` - ); - } - - return payload; -} - -async function seedSession(params: { - accessToken: string; - appState: Record; - baseUrl: string; - bypassSecret?: string; - elements: unknown[]; - expectedVersion: number; - sessionId: string; - traceId: string; -}): Promise { - const response = await fetch( - resolveUrl(params.baseUrl, "/api/diagrams/session-seed"), - { - method: "POST", - headers: createApiHeaders({ - accessToken: params.accessToken, - bypassSecret: params.bypassSecret, - traceId: params.traceId, - }), - body: JSON.stringify({ - sessionId: params.sessionId, - elements: params.elements, - appState: params.appState, - expectedVersion: params.expectedVersion, - traceId: params.traceId, - }), - } - ); - assertTraceHeader(response, params.traceId, "/api/diagrams/session-seed"); - const payload = await parseJsonResponse< - SessionSeedResponse | { error: string } - >(response); - if (!(response.ok && "status" in payload)) { - throw new Error( - `session-seed failed (${response.status}): ${JSON.stringify(payload)}` - ); - } - - if (payload.traceId !== params.traceId) { - throw new Error( - `session-seed trace mismatch: payload=${payload.traceId} expected=${params.traceId}` - ); - } - - return payload; -} - -async function waitForCanvas(page: PageLike): Promise { - const loaded = await waitForCondition( - () => - page.evaluate(() => { - return Boolean( - document.querySelector('[data-testid="diagram-canvas"]') || - document.querySelector(".excalidraw") - ); - }), - { timeoutMs: 45_000, label: "diagram-canvas" } - ); - if (!loaded) { - throw new Error(`Diagram canvas did not load. URL=${page.url()}`); - } -} - -async function waitForRunStatus( - page: PageLike, - statusSubstring: string, - timeoutMs = 90_000 -): Promise { - const reached = await waitForCondition( - () => - page.evaluate((expected) => { - const text = - document.querySelector('[data-testid="diagram-status-row"]') - ?.textContent ?? ""; - return text.includes(expected); - }, statusSubstring), - { timeoutMs, label: `run-status:${statusSubstring}` } - ); - if (!reached) { - throw new Error(`Run status did not reach "${statusSubstring}".`); - } -} - -async function getSessionVersion(page: PageLike): Promise { - const raw = await page.evaluate(() => { - return ( - document.querySelector('[data-testid="diagram-session-version"]') - ?.textContent ?? "" - ); - }); - const match = raw.match(SESSION_VERSION_REGEX); - if (!match) { - throw new Error(`Unable to parse diagram session version from "${raw}".`); - } - return Number.parseInt(match[0], 10); -} - -async function assertThreadHistoryLoaded(page: PageLike): Promise { - const historyLoaded = await waitForCondition( - () => - page.evaluate(() => { - const userMessages = document.querySelectorAll( - '[data-testid="diagram-chat-message-user"]' - ).length; - const assistantMessages = document.querySelectorAll( - '[data-testid="diagram-chat-message-assistant"]' - ).length; - const toolMessages = Array.from( - document.querySelectorAll('[data-testid="diagram-tool-message"]') - ); - const hasCompletedTool = toolMessages.some( - (node) => node.getAttribute("data-tool-status") === "completed" - ); - return userMessages > 0 && assistantMessages > 0 && hasCompletedTool; - }), - { timeoutMs: 45_000, label: "thread-history-loaded" } - ); - - if (!historyLoaded) { - throw new Error( - "Session loaded without expected thread history (user/assistant/tool messages)." - ); - } -} - -async function sendWebPrompt(page: PageLike, prompt: string): Promise { - await page.locator('[data-testid="diagram-chat-input"]').fill(prompt); - await page.locator('[data-testid="diagram-chat-send"]').click(); -} - -function assertPersistedRun(run: ThreadRunResponse, description: string): void { - if (run.status !== "persisted" || run.runStatus !== "persisted") { - throw new Error( - `Expected persisted ${description}; got status=${run.status} runStatus=${run.runStatus} error=${run.runError ?? "none"}` - ); - } -} - -async function runDeviceFlowAndGetToken(params: { - baseUrl: string; - bypassSecret?: string; - credentials: { email: string; password: string }; - page: PageLike; - reviewPage: { - evaluate: ((pageFunction: () => unknown) => Promise) | undefined; - screenshot: (options?: { - fullPage?: boolean; - }) => Promise>; - }; - cfg: ReturnType; -}): Promise { - console.log("[smoke] Starting real device OAuth flow..."); - const deviceStartTraceId = toTraceId("701"); - const started = await startDeviceFlow({ - baseUrl: params.baseUrl, - bypassSecret: params.bypassSecret, - traceId: deviceStartTraceId, - }); - - await completeDeviceAuthorization({ - page: params.page, - verificationUrl: started.verificationUrl, - userCode: started.userCode, - email: params.credentials.email, - password: params.credentials.password, - }); - await captureScreenshot( - params.reviewPage, - params.cfg, - "opencode-web-device-approval", - { - prompt: - "Confirm device authorization reached hosted auth approval and was submitted.", - } - ); - - console.log("[assert] Polling device token endpoint until success..."); - return pollDeviceFlowToken({ - baseUrl: params.baseUrl, - bypassSecret: params.bypassSecret, - deviceCode: started.deviceCode, - intervalSeconds: started.interval, - expiresInSeconds: started.expiresIn, - traceId: toTraceId("702"), - }); -} - -async function runCliCreateAndIdempotency(params: { - accessToken: string; - baseUrl: string; - bypassSecret?: string; -}): Promise { - console.log( - "[assert] CLI-style thread-run creates durable session/thread..." - ); - const promptMessageId = `prompt_e2e_cli_${Date.now()}`; - const cliRun = await runThreadPrompt({ - accessToken: params.accessToken, - baseUrl: params.baseUrl, - bypassSecret: params.bypassSecret, - prompt: - "Create a simple onboarding flowchart with steps: Sign up -> Verify email -> Complete profile.", - promptMessageId, - traceId: toTraceId("703"), - }); - assertPersistedRun(cliRun, "CLI thread-run"); - if (!(cliRun.sessionId && cliRun.threadId && cliRun.shareLink?.url)) { - throw new Error( - "CLI thread-run response missing session/thread/share data." - ); - } - - console.log( - "[assert] Idempotent promptMessageId does not create duplicate run..." - ); - const duplicateRun = await runThreadPrompt({ - accessToken: params.accessToken, - baseUrl: params.baseUrl, - bypassSecret: params.bypassSecret, - prompt: "This payload should dedupe by promptMessageId.", - promptMessageId, - sessionId: cliRun.sessionId, - traceId: toTraceId("704"), - }); - if (duplicateRun.promptMessageId !== promptMessageId) { - throw new Error("Duplicate run did not preserve original promptMessageId."); - } - if (duplicateRun.sessionId !== cliRun.sessionId) { - throw new Error("Duplicate run returned a different sessionId."); - } - - return cliRun; -} - -async function runWebContinuation(params: { - cfg: ReturnType; - cliRun: ThreadRunResponse; - page: PageLike; - reviewPage: { - evaluate: ((pageFunction: () => unknown) => Promise) | undefined; - screenshot: (options?: { - fullPage?: boolean; - }) => Promise>; - }; -}): Promise { - console.log( - "[smoke] Opening same session in web and verifying continuity..." - ); - await params.page.goto( - resolveUrl(params.cfg.baseUrl, `/diagrams/${params.cliRun.sessionId}`), - { - waitUntil: "domcontentloaded", - } - ); - await waitForCanvas(params.page); - await assertThreadHistoryLoaded(params.page); - const versionAfterCli = await getSessionVersion(params.page); - if (!(versionAfterCli > 0)) { - throw new Error( - `Expected version > 0 after CLI run, got ${versionAfterCli}.` - ); - } - await captureScreenshot( - params.reviewPage, - params.cfg, - "opencode-web-after-cli", - { - prompt: - "Confirm web session opened the same CLI-created diagram with existing chat/tool history.", - } - ); - - console.log( - "[assert] Continuing from web then API on same session/thread..." - ); - await sendWebPrompt( - params.page, - "Add a decision branch after Verify email: Approved or Retry." - ); - await waitForRunStatus(params.page, "Running", 60_000); - await waitForRunStatus(params.page, "Persisted", 120_000); - - const versionAfterWebPrompt = await getSessionVersion(params.page); - if (versionAfterWebPrompt < versionAfterCli) { - throw new Error( - `Version regressed after web prompt: ${versionAfterWebPrompt} < ${versionAfterCli}.` - ); - } - - return versionAfterWebPrompt; -} - -async function runApiResumeAndOccRecovery(params: { - accessToken: string; - baseUrl: string; - bypassSecret?: string; - cfg: ReturnType; - cliRun: ThreadRunResponse; - page: PageLike; - reviewPage: { - evaluate: ((pageFunction: () => unknown) => Promise) | undefined; - screenshot: (options?: { - fullPage?: boolean; - }) => Promise>; - }; - versionAfterWebPrompt: number; -}): Promise { - const apiResume = await runThreadPrompt({ - accessToken: params.accessToken, - baseUrl: params.baseUrl, - bypassSecret: params.bypassSecret, - prompt: "Add a final node named Done and connect the outgoing branch.", - promptMessageId: `prompt_e2e_cli_resume_${Date.now()}`, - sessionId: params.cliRun.sessionId, - traceId: toTraceId("705"), - }); - - assertPersistedRun(apiResume, "API resume run"); - if (apiResume.sessionId !== params.cliRun.sessionId) { - throw new Error("API resume returned a different sessionId."); - } - if (apiResume.threadId !== params.cliRun.threadId) { - throw new Error("API resume returned a different threadId."); - } - if ( - !( - typeof apiResume.latestSceneVersion === "number" && - apiResume.latestSceneVersion >= params.versionAfterWebPrompt - ) - ) { - throw new Error( - `Expected API resume to maintain or advance version (latest=${apiResume.latestSceneVersion}, web=${params.versionAfterWebPrompt}).` - ); - } - if ( - !(apiResume.elements && apiResume.appState && apiResume.latestSceneVersion) - ) { - throw new Error( - "API resume response missing elements/appState/version for OCC checks." - ); - } - - console.log("[assert] OCC conflict path is explicit and recoverable..."); - const conflictResult = await seedSession({ - accessToken: params.accessToken, - appState: apiResume.appState, - baseUrl: params.baseUrl, - bypassSecret: params.bypassSecret, - elements: apiResume.elements, - expectedVersion: Math.max(0, apiResume.latestSceneVersion - 1), - sessionId: apiResume.sessionId, - traceId: toTraceId("706"), - }); - if (conflictResult.status !== "conflict") { - throw new Error( - `Expected session-seed conflict with stale version, got ${conflictResult.status}.` - ); - } - if (conflictResult.latestSceneVersion !== apiResume.latestSceneVersion) { - throw new Error( - `Conflict response latest version mismatch: ${conflictResult.latestSceneVersion} vs ${apiResume.latestSceneVersion}.` - ); - } - - const recoverResult = await seedSession({ - accessToken: params.accessToken, - appState: apiResume.appState, - baseUrl: params.baseUrl, - bypassSecret: params.bypassSecret, - elements: apiResume.elements, - expectedVersion: conflictResult.latestSceneVersion, - sessionId: apiResume.sessionId, - traceId: toTraceId("707"), - }); - if (recoverResult.status !== "success") { - throw new Error( - `Expected session-seed recovery success, got ${recoverResult.status}.` - ); - } - if ( - recoverResult.latestSceneVersion !== - conflictResult.latestSceneVersion + 1 - ) { - throw new Error( - `Expected recovery to increment version by 1, got ${recoverResult.latestSceneVersion}.` - ); - } - - await params.page.goto( - resolveUrl(params.cfg.baseUrl, `/diagrams/${params.cliRun.sessionId}`), - { - waitUntil: "domcontentloaded", - } - ); - await waitForCanvas(params.page); - const finalVersion = await getSessionVersion(params.page); - if (finalVersion < recoverResult.latestSceneVersion) { - throw new Error( - `Web reload did not reflect recovered version. final=${finalVersion} expected>=${recoverResult.latestSceneVersion}` - ); - } - - await captureScreenshot( - params.reviewPage, - params.cfg, - "opencode-web-final-continuity", - { - prompt: - "Confirm final session still shows stable chat/tool continuity after API resume and OCC recovery.", - } - ); -} - -async function main() { - const cfg = loadConfig(); - const stagehand = await createStagehand(cfg); - const warnings: string[] = []; - const startedAt = new Date().toISOString(); - let status: "passed" | "failed" = "passed"; - let errorMessage = ""; - - try { - const credentials = resolveAuthCredentials(); - const page = await getActivePage(stagehand); - const reviewPage = { - screenshot: page.screenshot.bind(page), - evaluate: page.evaluate?.bind(page), - }; - - await resetBrowserState(page, cfg.baseUrl, cfg.vercelBypassSecret); - await ensureDesktopViewport(page); - await ensureSignedInForDiagrams(page, cfg.baseUrl); - - const accessToken = await runDeviceFlowAndGetToken({ - baseUrl: cfg.baseUrl, - bypassSecret: cfg.vercelBypassSecret, - credentials, - page, - reviewPage, - cfg, - }); - - const cliRun = await runCliCreateAndIdempotency({ - accessToken, - baseUrl: cfg.baseUrl, - bypassSecret: cfg.vercelBypassSecret, - }); - - const versionAfterWebPrompt = await runWebContinuation({ - cfg, - cliRun, - page, - reviewPage, - }); - - await runApiResumeAndOccRecovery({ - accessToken, - baseUrl: cfg.baseUrl, - bypassSecret: cfg.vercelBypassSecret, - cfg, - cliRun, - page, - reviewPage, - versionAfterWebPrompt, - }); - } catch (error) { - status = "failed"; - errorMessage = error instanceof Error ? error.message : String(error); - throw error; - } finally { - await writeScenarioSummary({ - outputDir: cfg.screenshotsDir, - summary: { - scenario: "opencode-web-continuity", - status, - warnings, - error: errorMessage || undefined, - baseUrl: cfg.baseUrl, - env: cfg.env, - startedAt, - finishedAt: new Date().toISOString(), - }, - }); - await shutdown(stagehand); - finalizeScenario(status); - } -} - -await main(); diff --git a/tests/e2e/src/scenarios/unauthenticated/visual-sanity.ts b/tests/e2e/src/scenarios/unauthenticated/visual-sanity.ts deleted file mode 100644 index 82fbc761..00000000 --- a/tests/e2e/src/scenarios/unauthenticated/visual-sanity.ts +++ /dev/null @@ -1,221 +0,0 @@ -/* -Scenario: Visual sanity check (public) - -Intent: A broad, low-precision sweep that walks the primary navigation and flags anything obviously broken or visually wrong. - -Steps: -- Start on home. -- Use visible navigation only to visit key top-level pages. -- Do not assert exact copy or counts; just look for obvious breakage. - -Success: -- No pages appear blank, misaligned, or visibly broken. -- Key sections render and the site feels usable. -*/ - -import { loadConfig } from "../../runner/config"; -import { - captureScreenshot, - createStagehand, - getActivePage, - shutdown, -} from "../../runner/stagehand"; -import { writeScenarioSummary } from "../../runner/summary"; -import { - ensureDesktopViewport, - finalizeScenario, - resetBrowserState, - resolveUrl, -} from "../../runner/utils"; -import { sleep, waitForVisible } from "../../runner/wait"; - -const themeModes = ["light", "dark"] as const; -const visualPrompt = - "Review this UI screenshot for obvious breakage (blank pages, loading states like 'Loading ...', overlap, cut-off content, unreadable text, low-contrast borders/dividers or invisible section boundaries). If there are no issues, respond with exactly: All looks OK."; - -async function waitForThemeClass( - page: { evaluate: (fn: () => T) => Promise }, - shouldBeDark: boolean -) { - const deadline = Date.now() + 2000; - while (Date.now() < deadline) { - const isDark = await page.evaluate( - () => - document.documentElement.classList.contains("dark") || - document.body.classList.contains("dark") - ); - if (isDark === shouldBeDark) { - return isDark; - } - await sleep(200); - } - return page.evaluate( - () => - document.documentElement.classList.contains("dark") || - document.body.classList.contains("dark") - ); -} - -// biome-ignore lint/suspicious/noExplicitAny: Playwright Page types -async function applyTheme(page: any, theme: "light" | "dark"): Promise { - const toggleSelector = '[data-testid="theme-toggle"]'; - const toggleVisible = await waitForVisible(page, toggleSelector, { - timeoutMs: 2000, - }); - - if (toggleVisible) { - const isDark = await page.evaluate( - () => - document.documentElement.classList.contains("dark") || - document.body.classList.contains("dark") - ); - const shouldBeDark = theme === "dark"; - if (isDark !== shouldBeDark) { - await page.locator(toggleSelector).click(); - await sleep(300); - } - } else { - await page.evaluate((value: string) => { - localStorage.setItem("theme", value); - }, theme); - await page.reload({ waitUntil: "domcontentloaded" }); - } -} - -function checkThemeApplied( - theme: "light" | "dark", - isDark: boolean, - isLocal: boolean, - warnings: string[], - visualIssues: string[] -): void { - const shouldBeDark = theme === "dark"; - - if (shouldBeDark && !isDark) { - const message = "dark mode did not apply"; - warnings.push(message); - if (!isLocal) { - visualIssues.push(message); - } - } - if (!shouldBeDark && isDark) { - const message = "light mode did not apply"; - warnings.push(message); - if (!isLocal) { - visualIssues.push(message); - } - } -} - -async function reviewThemeVisuals( - // biome-ignore lint/suspicious/noExplicitAny: Playwright Page type - page: any, - // biome-ignore lint/suspicious/noExplicitAny: config type - cfg: any, - theme: "light" | "dark", - strict: boolean, - warnings: string[], - visualIssues: string[] -): Promise { - const homeReview = await captureScreenshot( - page, - cfg, - `visual-${theme}-home`, - { prompt: visualPrompt } - ); - - if (homeReview?.hasIssues) { - const summary = homeReview.summary.replace(/\s+/g, " ").trim(); - visualIssues.push(summary); - if (strict) { - throw new Error(`visual review failed: ${summary}`); - } - warnings.push(`visual review: ${summary}`); - } -} - -async function main() { - const cfg = loadConfig(); - const stagehand = await createStagehand(cfg); - const warnings: string[] = []; - const visualIssues: string[] = []; - const startedAt = new Date().toISOString(); - let status: "passed" | "failed" = "passed"; - let errorMessage = ""; - const strict = process.env.STAGEHAND_VISUAL_STRICT === "true"; - const isLocal = cfg.env === "LOCAL"; - - try { - const page = await getActivePage(stagehand); - // biome-ignore lint/suspicious/noExplicitAny: Playwright Page types are complex; utility functions use structural typing - await resetBrowserState(page as any, cfg.baseUrl, cfg.vercelBypassSecret); - // biome-ignore lint/suspicious/noExplicitAny: Playwright Page types are complex; utility functions use structural typing - await ensureDesktopViewport(page as any); - - for (const theme of themeModes) { - await page.goto(resolveUrl(cfg.baseUrl, "/"), { - waitUntil: "domcontentloaded", - }); - - // biome-ignore lint/suspicious/noExplicitAny: Playwright Page types - await applyTheme(page as any, theme); - - const shouldBeDark = theme === "dark"; - const isDark = await waitForThemeClass(page, shouldBeDark); - - checkThemeApplied(theme, isDark, isLocal, warnings, visualIssues); - - await reviewThemeVisuals( - // biome-ignore lint/suspicious/noExplicitAny: Playwright Page type - page as any, - cfg, - theme, - strict, - warnings, - visualIssues - ); - } - - if (warnings.length > 0) { - console.log(`Visual navigation warnings:\n- ${warnings.join("\n- ")}`); - if (strict) { - throw new Error("Visual navigation warnings reported in strict mode."); - } - } - - if (cfg.env === "BROWSERBASE") { - const sessionId = stagehand.browserbaseSessionID; - if (!sessionId) { - console.log("Browserbase session ID unavailable."); - return; - } - - console.log( - `remember: open the Browserbase session browser and search for session ${sessionId} to manually confirm the replay` - ); - } - } catch (error) { - status = "failed"; - errorMessage = error instanceof Error ? error.message : String(error); - throw error; - } finally { - await writeScenarioSummary({ - outputDir: cfg.screenshotsDir, - summary: { - scenario: "visual-sanity", - status, - warnings, - visualIssues, - error: errorMessage || undefined, - baseUrl: cfg.baseUrl, - env: cfg.env, - startedAt, - finishedAt: new Date().toISOString(), - }, - }); - await shutdown(stagehand); - finalizeScenario(status); - } -} - -await main(); diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json deleted file mode 100644 index f5dc22c4..00000000 --- a/tests/e2e/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "resolveJsonModule": true, - "esModuleInterop": true, - "types": ["bun-types", "node"], - "skipLibCheck": true - }, - "include": ["src/**/*.ts", "scripts/**/*.ts"] -} diff --git a/tools/sketchi-generators/README.md b/tools/sketchi-generators/README.md new file mode 100644 index 00000000..44a675fe --- /dev/null +++ b/tools/sketchi-generators/README.md @@ -0,0 +1,43 @@ +# Sketchi Generators + +Local Nx generators for the v2 Sketchi workspace. + +## Diagram Types + +Use this when adding a new maintained diagram contract: + +```sh +pnpm nx g @sketchi/generators:diagram-type mindmap \ + --label="Mind map" \ + --description="Radial knowledge map contract" \ + --prompt="Show a radial mindmap fixture." +``` + +The generator creates the core fixture/test, renderer contract test, Studio +Storybook story, catalog entry, fixture registry entry, and package export. + +## Studio Components + +Use this before hand-authoring a reusable Studio component: + +```sh +pnpm nx g @sketchi/generators:ui-component diagram-status-strip +``` + +The generator creates a component file, test, Storybook story, local barrel +export, and package export. After generation, replace the placeholder props and +markup with the real component contract. + +The `diagram-studio-ui` test target includes a structure guard that fails if a +component under `src/components/*` is missing its test, story, local export, or +package export. + +## Checks + +```sh +pnpm nx test sketchi-generators +pnpm nx typecheck sketchi-generators +pnpm nx test diagram-studio-ui +pnpm nx build-storybook diagram-studio-ui +pnpm nx test-storybook diagram-studio-ui +``` diff --git a/tools/sketchi-generators/generators.json b/tools/sketchi-generators/generators.json new file mode 100644 index 00000000..1e863244 --- /dev/null +++ b/tools/sketchi-generators/generators.json @@ -0,0 +1,14 @@ +{ + "generators": { + "diagram-type": { + "factory": "./src/generators/diagram-type/diagram-type", + "schema": "./src/generators/diagram-type/schema.json", + "description": "Create a typed Sketchi diagram type with core, renderer, and Storybook coverage." + }, + "ui-component": { + "factory": "./src/generators/ui-component/ui-component", + "schema": "./src/generators/ui-component/schema.json", + "description": "Create a Sketchi UI component with tests and Storybook story." + } + } +} diff --git a/tools/sketchi-generators/package.json b/tools/sketchi-generators/package.json new file mode 100644 index 00000000..f80981c9 --- /dev/null +++ b/tools/sketchi-generators/package.json @@ -0,0 +1,13 @@ +{ + "name": "@sketchi/generators", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "@nx/devkit": "22.7.5", + "tslib": "^2.3.0" + }, + "generators": "./generators.json" +} diff --git a/tools/sketchi-generators/project.json b/tools/sketchi-generators/project.json new file mode 100644 index 00000000..d3d94e6b --- /dev/null +++ b/tools/sketchi-generators/project.json @@ -0,0 +1,60 @@ +{ + "name": "sketchi-generators", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "tools/sketchi-generators/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/tools/sketchi-generators", + "main": "tools/sketchi-generators/src/index.ts", + "tsConfig": "tools/sketchi-generators/tsconfig.lib.json", + "assets": [ + "tools/sketchi-generators/*.md", + { + "input": "./tools/sketchi-generators/src", + "glob": "**/!(*.ts)", + "output": "./src" + }, + { + "input": "./tools/sketchi-generators/src", + "glob": "**/*.d.ts", + "output": "./src" + }, + { + "input": "./tools/sketchi-generators", + "glob": "generators.json", + "output": "." + }, + { + "input": "./tools/sketchi-generators", + "glob": "executors.json", + "output": "." + } + ] + } + }, + "test": { + "executor": "@nx/vitest:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "coverage/tools/sketchi-generators" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "tsc -p tools/sketchi-generators/tsconfig.json --noEmit" + } + }, + "typecheck-tsgo": { + "executor": "nx:run-commands", + "options": { + "command": "tsgo -p tools/sketchi-generators/tsconfig.json --noEmit" + } + } + } +} diff --git a/tools/sketchi-generators/src/generators/diagram-type/diagram-type.spec.ts b/tools/sketchi-generators/src/generators/diagram-type/diagram-type.spec.ts new file mode 100644 index 00000000..fcbc8dcc --- /dev/null +++ b/tools/sketchi-generators/src/generators/diagram-type/diagram-type.spec.ts @@ -0,0 +1,126 @@ +import type { Tree } from "@nx/devkit"; +import { createTreeWithEmptyWorkspace } from "@nx/devkit/testing"; + +import { diagramTypeGenerator } from "./diagram-type"; +import type { DiagramTypeGeneratorSchema } from "./schema"; + +describe("diagram-type generator", () => { + let tree: Tree; + const options: DiagramTypeGeneratorSchema = { + description: "Radial knowledge map contract", + label: "Mind map", + name: "mindmap", + prompt: "Show a radial mindmap fixture.", + title: "Generated mindmap", + skipFormat: true, + }; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tree.write( + "packages/diagram-core/src/diagram-types.ts", + `export const DIAGRAM_TYPES = [ + "architecture", + "flowchart" +] as const; +` + ); + tree.write( + "packages/diagram-core/src/fixtures.ts", + `import { architectureFixture } from "./diagram-types/architecture"; +import { flowchartFixture } from "./diagram-types/flowchart"; + +export const diagramFixtures = { + "architecture": architectureFixture, + "flowchart": flowchartFixture, +} as const; +` + ); + tree.write( + "packages/diagram-core/src/diagram-catalog.ts", + `import { architectureFixture } from "./diagram-types/architecture"; +import { flowchartFixture } from "./diagram-types/flowchart"; +import type { IntermediateDiagram } from "./intermediate"; + +export interface DiagramCatalogEntry { + description: string; + diagram: IntermediateDiagram; + id: string; + label: string; + prompt: string; +} + +export const generatedDiagramCatalog = [ + { + description: "Architecture fixture", + diagram: architectureFixture, + id: "architecture", + label: "Architecture", + prompt: "Render architecture.", + }, + { + description: "Flowchart fixture", + diagram: flowchartFixture, + id: "flowchart", + label: "Flowchart", + prompt: "Render flowchart.", + }, +] satisfies DiagramCatalogEntry[]; +` + ); + tree.write("packages/diagram-core/src/index.ts", ""); + }); + + it("creates diagram fixtures, renderer contract, Storybook story, catalog entry, and registry export", async () => { + await diagramTypeGenerator(tree, options); + + expect( + tree.read("packages/diagram-core/src/diagram-types.ts", "utf-8") + ).toContain('"mindmap"'); + expect( + tree.read("packages/diagram-core/src/fixtures.ts", "utf-8") + ).toContain('"mindmap": mindmapFixture'); + const catalog = tree.read( + "packages/diagram-core/src/diagram-catalog.ts", + "utf-8" + ); + expect(catalog).toContain( + 'import { mindmapFixture } from "./diagram-types/mindmap";' + ); + expect(catalog).toContain('id: "mindmap"'); + expect(catalog).toContain('label: "Mind map"'); + expect(catalog).toContain('description: "Radial knowledge map contract"'); + expect(catalog).toContain('prompt: "Show a radial mindmap fixture."'); + expect( + tree.exists("packages/diagram-core/src/diagram-types/mindmap.ts") + ).toBe(true); + expect( + tree.exists("packages/diagram-renderer/src/diagram-types/mindmap.test.ts") + ).toBe(true); + expect( + tree.exists( + "packages/diagram-studio-ui/src/diagram-types/mindmap.stories.tsx" + ) + ).toBe(true); + expect(tree.read("packages/diagram-core/src/index.ts", "utf-8")).toContain( + 'export * from "./diagram-types/mindmap";' + ); + }); + + it("does not duplicate a diagram type that is already in the registry", async () => { + await diagramTypeGenerator(tree, { + name: "flowchart", + skipFormat: true, + }); + + const registry = tree.read( + "packages/diagram-core/src/diagram-types.ts", + "utf-8" + ); + + expect(registry.match(/"flowchart"/g)).toHaveLength(1); + expect( + tree.exists("packages/diagram-core/src/diagram-types/flowchart.ts") + ).toBe(true); + }); +}); diff --git a/tools/sketchi-generators/src/generators/diagram-type/diagram-type.ts b/tools/sketchi-generators/src/generators/diagram-type/diagram-type.ts new file mode 100644 index 00000000..75162d89 --- /dev/null +++ b/tools/sketchi-generators/src/generators/diagram-type/diagram-type.ts @@ -0,0 +1,238 @@ +import path from "node:path"; +import { + formatFiles, + generateFiles, + joinPathFragments, + names, + type Tree, +} from "@nx/devkit"; + +import type { DiagramTypeGeneratorSchema } from "./schema"; + +const CORE_ROOT = "packages/diagram-core/src"; +const RENDERER_ROOT = "packages/diagram-renderer/src"; +const STUDIO_ROOT = "packages/diagram-studio-ui/src"; +const CATALOG_ITEMS_MARKER = "] satisfies DiagramCatalogEntry[];"; +const FIXTURE_MAP_MARKER = "} as const;"; +const REGISTRY_TRAILING_VALUE_PATTERN = /(\S)(\s*)$/; + +function appendExport(tree: Tree, indexPath: string, exportPath: string) { + const exportLine = `export * from "${exportPath}";`; + const existing = tree.exists(indexPath) + ? (tree.read(indexPath, "utf-8") ?? "") + : ""; + + if (existing.includes(exportLine)) { + return; + } + + tree.write(indexPath, `${existing.trimEnd()}\n${exportLine}\n`); +} + +function addDiagramTypeToRegistry(tree: Tree, typeValue: string) { + const registryPath = joinPathFragments(CORE_ROOT, "diagram-types.ts"); + + if (!tree.exists(registryPath)) { + throw new Error(`Missing diagram type registry at ${registryPath}.`); + } + + const registry = tree.read(registryPath, "utf-8") ?? ""; + + if (registry.includes(`"${typeValue}"`)) { + return; + } + + const marker = "] as const;"; + const markerIndex = registry.indexOf(marker); + + if (markerIndex === -1) { + throw new Error( + `Could not update diagram type registry at ${registryPath}.` + ); + } + + const beforeMarker = registry.slice(0, markerIndex); + const afterMarker = registry.slice(markerIndex); + const beforeInsertion = beforeMarker.trimEnd().endsWith(",") + ? beforeMarker + : beforeMarker.replace(REGISTRY_TRAILING_VALUE_PATTERN, "$1,$2"); + const nextRegistry = `${beforeInsertion} "${typeValue}",\n${afterMarker}`; + + tree.write(registryPath, nextRegistry); +} + +function prependImportIfMissing( + tree: Tree, + filePath: string, + importLine: string +) { + const existing = tree.exists(filePath) + ? (tree.read(filePath, "utf-8") ?? "") + : ""; + + if (existing.includes(importLine)) { + return existing; + } + + const nextSource = `${importLine}\n${existing}`; + tree.write(filePath, nextSource); + + return nextSource; +} + +function insertBeforeMarker( + tree: Tree, + filePath: string, + marker: string, + content: string, + alreadyPresentText: string +) { + const source = tree.exists(filePath) + ? (tree.read(filePath, "utf-8") ?? "") + : ""; + + if (source.includes(alreadyPresentText)) { + return; + } + + const markerIndex = source.indexOf(marker); + + if (markerIndex === -1) { + throw new Error(`Could not update ${filePath}; missing marker ${marker}.`); + } + + tree.write( + filePath, + `${source.slice(0, markerIndex)}${content}${source.slice(markerIndex)}` + ); +} + +function addDiagramFixture(tree: Tree, typeValue: string, fixtureName: string) { + const fixturesPath = joinPathFragments(CORE_ROOT, "fixtures.ts"); + + if (!tree.exists(fixturesPath)) { + throw new Error(`Missing fixture registry at ${fixturesPath}.`); + } + + prependImportIfMissing( + tree, + fixturesPath, + `import { ${fixtureName} } from "./diagram-types/${typeValue}";` + ); + insertBeforeMarker( + tree, + fixturesPath, + FIXTURE_MAP_MARKER, + ` "${typeValue}": ${fixtureName},\n`, + `"${typeValue}":` + ); +} + +function addDiagramCatalogEntry( + tree: Tree, + options: { + description: string; + fixtureName: string; + label: string; + prompt: string; + typeValue: string; + } +) { + const catalogPath = joinPathFragments(CORE_ROOT, "diagram-catalog.ts"); + + if (!tree.exists(catalogPath)) { + throw new Error(`Missing diagram catalog at ${catalogPath}.`); + } + + prependImportIfMissing( + tree, + catalogPath, + `import { ${options.fixtureName} } from "./diagram-types/${options.typeValue}";` + ); + insertBeforeMarker( + tree, + catalogPath, + CATALOG_ITEMS_MARKER, + ` { + description: ${JSON.stringify(options.description)}, + diagram: ${options.fixtureName}, + id: ${JSON.stringify(options.typeValue)}, + label: ${JSON.stringify(options.label)}, + prompt: ${JSON.stringify(options.prompt)}, + }, +`, + `id: ${JSON.stringify(options.typeValue)}` + ); +} + +export async function diagramTypeGenerator( + tree: Tree, + options: DiagramTypeGeneratorSchema +) { + const normalizedName = names(options.name); + const typeValue = normalizedName.fileName; + const title = options.title ?? `${normalizedName.className} diagram`; + const label = options.label ?? normalizedName.className; + const description = options.description ?? `Generated ${title} fixture`; + const prompt = options.prompt ?? `Render the ${title} fixture.`; + const fixtureName = `${normalizedName.propertyName}Fixture`; + const coreFilePath = joinPathFragments( + CORE_ROOT, + "diagram-types", + `${typeValue}.ts` + ); + const templateContext = { + className: normalizedName.className, + description, + fixtureName, + label, + prompt, + propertyName: normalizedName.propertyName, + title, + typeValue, + }; + + if (tree.exists(coreFilePath)) { + throw new Error(`Diagram type already exists at ${coreFilePath}.`); + } + + addDiagramTypeToRegistry(tree, typeValue); + addDiagramFixture(tree, typeValue, fixtureName); + addDiagramCatalogEntry(tree, { + description, + fixtureName, + label, + prompt, + typeValue, + }); + generateFiles( + tree, + path.join(__dirname, "files", "core"), + joinPathFragments(CORE_ROOT, "diagram-types"), + templateContext + ); + generateFiles( + tree, + path.join(__dirname, "files", "renderer"), + joinPathFragments(RENDERER_ROOT, "diagram-types"), + templateContext + ); + generateFiles( + tree, + path.join(__dirname, "files", "studio"), + joinPathFragments(STUDIO_ROOT, "diagram-types"), + templateContext + ); + + appendExport( + tree, + joinPathFragments(CORE_ROOT, "index.ts"), + `./diagram-types/${typeValue}` + ); + + if (!options.skipFormat) { + await formatFiles(tree); + } +} + +export default diagramTypeGenerator; diff --git a/tools/sketchi-generators/src/generators/diagram-type/files/core/__typeValue__.test.ts.template b/tools/sketchi-generators/src/generators/diagram-type/files/core/__typeValue__.test.ts.template new file mode 100644 index 00000000..2cb7bd64 --- /dev/null +++ b/tools/sketchi-generators/src/generators/diagram-type/files/core/__typeValue__.test.ts.template @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +import { parseIntermediateDiagram } from "../intermediate"; +import { <%= fixtureName %>, <%= propertyName %>DiagramType } from "./<%= typeValue %>"; + +describe("<%= className %> diagram type", () => { + it("has a typed fixture that satisfies the intermediate diagram contract", () => { + expect(<%= fixtureName %>.graphOptions?.diagramType).toBe(<%= propertyName %>DiagramType); + expect(parseIntermediateDiagram(<%= fixtureName %>)).toEqual(<%= fixtureName %>); + }); +}); diff --git a/tools/sketchi-generators/src/generators/diagram-type/files/core/__typeValue__.ts.template b/tools/sketchi-generators/src/generators/diagram-type/files/core/__typeValue__.ts.template new file mode 100644 index 00000000..aebd05b4 --- /dev/null +++ b/tools/sketchi-generators/src/generators/diagram-type/files/core/__typeValue__.ts.template @@ -0,0 +1,35 @@ +import type { IntermediateDiagram } from "../intermediate"; + +export const <%= propertyName %>DiagramType = "<%= typeValue %>" as const; + +export const <%= fixtureName %> = { + edges: [ + { + fromId: "root", + id: "edge-root-first-branch", + label: "expands", + toId: "first-branch", + }, + { + fromId: "root", + id: "edge-root-second-branch", + label: "connects", + toId: "second-branch", + }, + ], + graphOptions: { + diagramType: <%= propertyName %>DiagramType, + layout: { direction: "LR", edgeRouting: "elbow" }, + style: { + arrowStroke: "#334155", + shapeFill: "#f3e8ff", + shapeStroke: "#7c3aed", + textColor: "#111827", + }, + }, + nodes: [ + { id: "root", kind: "root", label: "<%= className %>" }, + { id: "first-branch", kind: "branch", label: "First branch" }, + { id: "second-branch", kind: "branch", label: "Second branch" }, + ], +} satisfies IntermediateDiagram; diff --git a/tools/sketchi-generators/src/generators/diagram-type/files/renderer/__typeValue__.test.ts.template b/tools/sketchi-generators/src/generators/diagram-type/files/renderer/__typeValue__.test.ts.template new file mode 100644 index 00000000..5cb4e5b2 --- /dev/null +++ b/tools/sketchi-generators/src/generators/diagram-type/files/renderer/__typeValue__.test.ts.template @@ -0,0 +1,18 @@ +import { <%= fixtureName %> } from "@sketchi/diagram-core"; +import { describe, expect, it } from "vitest"; + +import { renderIntermediateDiagram } from "../scene"; + +describe("<%= className %> renderer contract", () => { + it("renders the generated fixture into deterministic scene elements", () => { + const scene = renderIntermediateDiagram(<%= fixtureName %>); + + expect(scene.appState.diagramType).toBe("<%= typeValue %>"); + expect(scene.stats.nodeCount).toBe(<%= fixtureName %>.nodes.length); + expect(scene.stats.edgeCount).toBe(<%= fixtureName %>.edges.length); + expect(scene.elements.filter((element) => element.type === "rectangle")) + .toHaveLength(<%= fixtureName %>.nodes.length); + expect(scene.elements.filter((element) => element.type === "arrow")) + .toHaveLength(<%= fixtureName %>.edges.length); + }); +}); diff --git a/tools/sketchi-generators/src/generators/diagram-type/files/studio/__typeValue__.stories.tsx.template b/tools/sketchi-generators/src/generators/diagram-type/files/studio/__typeValue__.stories.tsx.template new file mode 100644 index 00000000..a70f5be8 --- /dev/null +++ b/tools/sketchi-generators/src/generators/diagram-type/files/studio/__typeValue__.stories.tsx.template @@ -0,0 +1,29 @@ +import { <%= fixtureName %> } from "@sketchi/diagram-core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { GenerationWorkspace } from "../components/generation-workspace"; +import "../styles.css"; + +const meta = { + component: GenerationWorkspace, + title: "Diagram Types/<%= className %>", + args: { + diagrams: [ + { + description: <%- JSON.stringify(description) %>, + diagram: <%= fixtureName %>, + id: "<%= typeValue %>", + label: <%- JSON.stringify(label) %>, + prompt: <%- JSON.stringify(prompt) %>, + }, + ], + selectedDiagramId: "<%= typeValue %>", + status: "rendered", + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Rendered: Story = {}; diff --git a/tools/sketchi-generators/src/generators/diagram-type/schema.d.ts b/tools/sketchi-generators/src/generators/diagram-type/schema.d.ts new file mode 100644 index 00000000..7775d23b --- /dev/null +++ b/tools/sketchi-generators/src/generators/diagram-type/schema.d.ts @@ -0,0 +1,8 @@ +export interface DiagramTypeGeneratorSchema { + description?: string; + label?: string; + name: string; + prompt?: string; + skipFormat?: boolean; + title?: string; +} diff --git a/tools/sketchi-generators/src/generators/diagram-type/schema.json b/tools/sketchi-generators/src/generators/diagram-type/schema.json new file mode 100644 index 00000000..e19b6b29 --- /dev/null +++ b/tools/sketchi-generators/src/generators/diagram-type/schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "DiagramType", + "title": "", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Diagram type value, for example mindmap or system-map.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What diagram type would you like to use?" + }, + "title": { + "type": "string", + "description": "Human-readable fixture title." + }, + "label": { + "type": "string", + "description": "Human-readable catalog label. Defaults to the PascalCase diagram name." + }, + "description": { + "type": "string", + "description": "Catalog description shown in the generated Studio UI." + }, + "prompt": { + "type": "string", + "description": "Prompt text shown alongside the generated fixture." + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting generated files.", + "default": false + } + }, + "required": ["name"] +} diff --git a/tools/sketchi-generators/src/generators/ui-component/files/__fileName__.stories.tsx.template b/tools/sketchi-generators/src/generators/ui-component/files/__fileName__.stories.tsx.template new file mode 100644 index 00000000..5555d42c --- /dev/null +++ b/tools/sketchi-generators/src/generators/ui-component/files/__fileName__.stories.tsx.template @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { <%= className %> } from "./<%= fileName %>"; + +const meta = { + title: "<%= storyTitle %>", + component: <%= className %>, + args: { + title: "<%= className %>", + }, +} satisfies Meta>; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/tools/sketchi-generators/src/generators/ui-component/files/__fileName__.test.tsx.template b/tools/sketchi-generators/src/generators/ui-component/files/__fileName__.test.tsx.template new file mode 100644 index 00000000..f5af4daa --- /dev/null +++ b/tools/sketchi-generators/src/generators/ui-component/files/__fileName__.test.tsx.template @@ -0,0 +1,12 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { <%= className %> } from "./<%= fileName %>"; + +describe("<%= className %>", () => { + it("renders the title", () => { + render(<<%= className %> title="Generated component" />); + + expect(screen.getByRole("heading", { name: "Generated component" })).toBeTruthy(); + }); +}); diff --git a/tools/sketchi-generators/src/generators/ui-component/files/__fileName__.tsx.template b/tools/sketchi-generators/src/generators/ui-component/files/__fileName__.tsx.template new file mode 100644 index 00000000..7d9c2cee --- /dev/null +++ b/tools/sketchi-generators/src/generators/ui-component/files/__fileName__.tsx.template @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +export interface <%= className %>Props { + title?: string; + children?: ReactNode; +} + +export function <%= className %>({ + title = "<%= className %>", + children, +}: <%= className %>Props) { + return ( +
+

{title}

+ {children ?
{children}
: null} +
+ ); +} diff --git a/tools/sketchi-generators/src/generators/ui-component/files/index.ts.template b/tools/sketchi-generators/src/generators/ui-component/files/index.ts.template new file mode 100644 index 00000000..19a8d2da --- /dev/null +++ b/tools/sketchi-generators/src/generators/ui-component/files/index.ts.template @@ -0,0 +1 @@ +export * from "./<%= fileName %>"; diff --git a/tools/sketchi-generators/src/generators/ui-component/schema.d.ts b/tools/sketchi-generators/src/generators/ui-component/schema.d.ts new file mode 100644 index 00000000..a92d6091 --- /dev/null +++ b/tools/sketchi-generators/src/generators/ui-component/schema.d.ts @@ -0,0 +1,6 @@ +export interface UiComponentGeneratorSchema { + directory?: string; + name: string; + projectRoot?: string; + skipFormat?: boolean; +} diff --git a/tools/sketchi-generators/src/generators/ui-component/schema.json b/tools/sketchi-generators/src/generators/ui-component/schema.json new file mode 100644 index 00000000..e3073b28 --- /dev/null +++ b/tools/sketchi-generators/src/generators/ui-component/schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "UiComponent", + "title": "", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Component name, for example DiagramPreview or diagram-preview.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What component name would you like to use?" + }, + "projectRoot": { + "type": "string", + "description": "Nx project root that owns the component.", + "default": "packages/diagram-studio-ui" + }, + "directory": { + "type": "string", + "description": "Directory under the project src folder.", + "default": "components" + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting generated files.", + "default": false + } + }, + "required": ["name"] +} diff --git a/tools/sketchi-generators/src/generators/ui-component/ui-component.spec.ts b/tools/sketchi-generators/src/generators/ui-component/ui-component.spec.ts new file mode 100644 index 00000000..a46a4fe3 --- /dev/null +++ b/tools/sketchi-generators/src/generators/ui-component/ui-component.spec.ts @@ -0,0 +1,40 @@ +import type { Tree } from "@nx/devkit"; +import { createTreeWithEmptyWorkspace } from "@nx/devkit/testing"; +import type { UiComponentGeneratorSchema } from "./schema"; +import { uiComponentGenerator } from "./ui-component"; + +describe("ui-component generator", () => { + let tree: Tree; + const options: UiComponentGeneratorSchema = { + name: "Status Badge", + skipFormat: true, + }; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tree.write("packages/diagram-studio-ui/src/index.ts", ""); + }); + + it("creates a component, test, story, and export", async () => { + await uiComponentGenerator(tree, options); + + expect( + tree.exists( + "packages/diagram-studio-ui/src/components/status-badge/status-badge.tsx" + ) + ).toBe(true); + expect( + tree.exists( + "packages/diagram-studio-ui/src/components/status-badge/status-badge.test.tsx" + ) + ).toBe(true); + expect( + tree.exists( + "packages/diagram-studio-ui/src/components/status-badge/status-badge.stories.tsx" + ) + ).toBe(true); + expect( + tree.read("packages/diagram-studio-ui/src/index.ts", "utf-8") + ).toContain('export * from "./components/status-badge";'); + }); +}); diff --git a/tools/sketchi-generators/src/generators/ui-component/ui-component.ts b/tools/sketchi-generators/src/generators/ui-component/ui-component.ts new file mode 100644 index 00000000..f1e6d617 --- /dev/null +++ b/tools/sketchi-generators/src/generators/ui-component/ui-component.ts @@ -0,0 +1,65 @@ +import path from "node:path"; +import { + formatFiles, + generateFiles, + joinPathFragments, + names, + type Tree, +} from "@nx/devkit"; + +import type { UiComponentGeneratorSchema } from "./schema"; + +const DEFAULT_PROJECT_ROOT = "packages/diagram-studio-ui"; +const DEFAULT_DIRECTORY = "components"; + +function appendExport(tree: Tree, indexPath: string, exportPath: string) { + const exportLine = `export * from "${exportPath}";`; + const existing = tree.exists(indexPath) + ? (tree.read(indexPath, "utf-8") ?? "") + : ""; + + if (existing.includes(exportLine)) { + return; + } + + tree.write(indexPath, `${existing.trimEnd()}\n${exportLine}\n`); +} + +export async function uiComponentGenerator( + tree: Tree, + options: UiComponentGeneratorSchema +) { + const normalizedName = names(options.name); + const projectRoot = options.projectRoot ?? DEFAULT_PROJECT_ROOT; + const directory = options.directory ?? DEFAULT_DIRECTORY; + const sourceRoot = joinPathFragments(projectRoot, "src"); + const componentRoot = joinPathFragments( + sourceRoot, + directory, + normalizedName.fileName + ); + const componentPath = joinPathFragments( + componentRoot, + `${normalizedName.fileName}.tsx` + ); + const indexPath = joinPathFragments(sourceRoot, "index.ts"); + const exportPath = `./${directory}/${normalizedName.fileName}`; + const storyTitle = `Diagram Studio/Components/${normalizedName.className}`; + + if (tree.exists(componentPath)) { + throw new Error(`Component already exists at ${componentPath}.`); + } + + generateFiles(tree, path.join(__dirname, "files"), componentRoot, { + className: normalizedName.className, + fileName: normalizedName.fileName, + storyTitle, + }); + appendExport(tree, indexPath, exportPath); + + if (!options.skipFormat) { + await formatFiles(tree); + } +} + +export default uiComponentGenerator; diff --git a/tests/e2e/artifacts/.gitkeep b/tools/sketchi-generators/src/index.ts similarity index 100% rename from tests/e2e/artifacts/.gitkeep rename to tools/sketchi-generators/src/index.ts diff --git a/tools/sketchi-generators/tsconfig.json b/tools/sketchi-generators/tsconfig.json new file mode 100644 index 00000000..89eb062a --- /dev/null +++ b/tools/sketchi-generators/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/tools/sketchi-generators/tsconfig.lib.json b/tools/sketchi-generators/tsconfig.lib.json new file mode 100644 index 00000000..54b671b6 --- /dev/null +++ b/tools/sketchi-generators/tsconfig.lib.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx" + ] +} diff --git a/tools/sketchi-generators/tsconfig.spec.json b/tools/sketchi-generators/tsconfig.spec.json new file mode 100644 index 00000000..56b74888 --- /dev/null +++ b/tools/sketchi-generators/tsconfig.spec.json @@ -0,0 +1,28 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/tools/sketchi-generators/vitest.config.mts b/tools/sketchi-generators/vitest.config.mts new file mode 100644 index 00000000..ae1897d0 --- /dev/null +++ b/tools/sketchi-generators/vitest.config.mts @@ -0,0 +1,21 @@ +import { nxCopyAssetsPlugin } from "@nx/vite/plugins/nx-copy-assets.plugin"; +import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; +import { defineConfig } from "vitest/config"; + +export default defineConfig(() => ({ + root: import.meta.dirname, + cacheDir: "../../node_modules/.vite/tools/sketchi-generators", + plugins: [nxViteTsPaths(), nxCopyAssetsPlugin(["*.md"])], + test: { + name: "sketchi-generators", + watch: false, + globals: true, + environment: "jsdom", + include: ["{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + reporters: ["default"], + coverage: { + reportsDirectory: "../../coverage/tools/sketchi-generators", + provider: "v8" as const, + }, + }, +})); diff --git a/tools/storybook/verify-diagram-studio-stories.mjs b/tools/storybook/verify-diagram-studio-stories.mjs new file mode 100644 index 00000000..7b78b9a7 --- /dev/null +++ b/tools/storybook/verify-diagram-studio-stories.mjs @@ -0,0 +1,85 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +const workspaceRoot = process.cwd(); +const projectRoot = path.join(workspaceRoot, "packages", "diagram-studio-ui"); +const storybookRoot = path.join( + workspaceRoot, + "dist", + "storybook", + "diagram-studio-ui" +); +const storyIndexPath = path.join(storybookRoot, "index.json"); +const sourceStoryRoots = [ + path.join(projectRoot, "src", "components"), + path.join(projectRoot, "src", "diagram-types"), +]; + +function listStoryFiles(root) { + if (!existsSync(root)) { + return []; + } + + return readdirSync(root) + .flatMap((entry) => { + const entryPath = path.join(root, entry); + + if (statSync(entryPath).isDirectory()) { + return listStoryFiles(entryPath); + } + + return entryPath.endsWith(".stories.tsx") ? [entryPath] : []; + }) + .sort(); +} + +function toStorybookImportPath(filePath) { + const relativePath = path + .relative(projectRoot, filePath) + .split(path.sep) + .join("/"); + + return `./${relativePath}`; +} + +function readStoryEntries() { + if (!existsSync(storyIndexPath)) { + throw new Error( + `Missing Storybook index at ${storyIndexPath}. Run the build-storybook target first.` + ); + } + + const storyIndex = JSON.parse(readFileSync(storyIndexPath, "utf-8")); + + return Object.values(storyIndex.entries ?? {}).filter( + (entry) => entry.type === "story" + ); +} + +const expectedStoryPaths = sourceStoryRoots + .flatMap(listStoryFiles) + .map((filePath) => ({ + filePath, + importPath: toStorybookImportPath(filePath), + })); +const storyEntries = readStoryEntries(); +const indexedImportPaths = new Set( + storyEntries.map((entry) => entry.importPath).filter(Boolean) +); +const missingStoryPaths = expectedStoryPaths.filter( + ({ importPath }) => !indexedImportPaths.has(importPath) +); + +if (missingStoryPaths.length > 0) { + console.error("Storybook is missing source stories:"); + + for (const { importPath } of missingStoryPaths) { + console.error(`- ${importPath}`); + } + + process.exit(1); +} + +console.log( + `Verified ${expectedStoryPaths.length} source story files across ${storyEntries.length} static Storybook stories.` +); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..5e2be80d --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "paths": { + "@sketchi/diagram-agent-tools": [ + "./packages/diagram-agent-tools/src/index.ts" + ], + "@sketchi/diagram-core": ["./packages/diagram-core/src/index.ts"], + "@sketchi/diagram-renderer": ["./packages/diagram-renderer/src/index.ts"], + "@sketchi/diagram-studio-ui": [ + "./packages/diagram-studio-ui/src/index.ts" + ], + "@sketchi/generators": ["./tools/sketchi-generators/src/index.ts"], + "@sketchi/mcp-server": ["./packages/mcp-server/src/index.ts"], + "@sketchi/diagram-exporter": ["./packages/diagram-exporter/src/index.ts"] + }, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"] + } +} diff --git a/tsconfig.json b/tsconfig.json index 1ea67954..f71f754e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,10 @@ { - "extends": "@sketchi/config/tsconfig.base.json", - "compilerOptions": { - "strictNullChecks": true - } + "extends": "./tsconfig.base.json", + "files": [], + "references": [ + { "path": "apps/web" }, + { "path": "packages/diagram-core" }, + { "path": "packages/diagram-renderer" }, + { "path": "packages/diagram-studio-ui" } + ] } diff --git a/turbo.json b/turbo.json deleted file mode 100644 index 7f4e37d0..00000000 --- a/turbo.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "https://turbo.build/schema.json", - "ui": "stream", - "tasks": { - "build": { - "dependsOn": ["^build"], - "inputs": ["$TURBO_DEFAULT$", ".env*"], - "outputs": ["dist/**", ".next/**", "!.next/cache/**"], - "env": [ - "CONVEX_DEPLOY_KEY", - "NEXT_PUBLIC_CONVEX_URL", - "NEXT_PUBLIC_SENTRY_DSN", - "WORKOS_API_KEY", - "WORKOS_CLIENT_ID", - "WORKOS_COOKIE_PASSWORD", - "SENTRY_AUTH_TOKEN", - "SENTRY_ORG", - "SENTRY_PROJECT", - "SENTRY_PUBLIC_KEY", - "SENTRY_OTLP_TRACES_URL", - "SENTRY_VERCEL_LOG_DRAIN_URL" - ] - }, - "lint": { - "dependsOn": ["^lint"] - }, - "check-types": { - "dependsOn": ["^check-types"] - }, - "dev": { - "cache": false, - "persistent": true - }, - "dev:setup": { - "cache": false, - "persistent": true - } - } -} diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..87056607 --- /dev/null +++ b/vercel.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "git": { + "deploymentEnabled": false + } +} diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 00000000..9e607b26 --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1,7 @@ +import { defineWorkspace } from "vitest/config"; + +export default defineWorkspace([ + "packages/diagram-core/vitest.config.ts", + "packages/diagram-renderer/vitest.config.ts", + "packages/diagram-studio-ui/vitest.config.ts", +]);