diff --git a/.github/actions/copilot-test/action.yml b/.github/actions/copilot-test/action.yml new file mode 100644 index 000000000..6c89bbde3 --- /dev/null +++ b/.github/actions/copilot-test/action.yml @@ -0,0 +1,36 @@ +name: 'Run Copilot E2E Test' +description: 'Run Copilot E2E Test' +inputs: + script: + description: 'Script to run' + default: 'yarn workspace @affine-test/affine-cloud-copilot e2e --forbid-only' + required: false + openai-key: + description: 'OpenAI secret key' + required: true + fal-key: + description: 'Fal secret key' + required: true + +runs: + using: 'composite' + steps: + - name: Prepare Server Test Environment + uses: ./.github/actions/server-test-env + + - name: Server Copilot E2E Test + shell: bash + run: ${{ inputs.script }} + env: + COPILOT: true + DEV_SERVER_URL: http://localhost:8080 + COPILOT_OPENAI_API_KEY: ${{ inputs.openai-key }} + COPILOT_FAL_API_KEY: ${{ inputs.fal-key }} + + - name: Upload test results + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: test-results-e2e-server-copilot + path: ./test-results + if-no-files-found: ignore diff --git a/.github/actions/server-test-env/action.yml b/.github/actions/server-test-env/action.yml new file mode 100644 index 000000000..b296b98ab --- /dev/null +++ b/.github/actions/server-test-env/action.yml @@ -0,0 +1,21 @@ +name: 'Prepare Server Test Environment' +description: 'Prepare Server Test Environment' + +runs: + using: 'composite' + steps: + - name: Initialize database + shell: bash + run: | + psql -h localhost -U postgres -c "CREATE DATABASE affine;" + psql -h localhost -U postgres -c "CREATE USER affine WITH PASSWORD 'affine';" + psql -h localhost -U postgres -c "ALTER USER affine WITH SUPERUSER;" + env: + PGPASSWORD: affine + + - name: Run init-db script + shell: bash + run: | + yarn workspace @affine/server exec prisma generate + yarn workspace @affine/server exec prisma db push + yarn workspace @affine/server data-migration run diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index 1cc4b1494..6afb23035 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -17,6 +17,10 @@ inputs: description: 'Download the Electron binary' required: false default: 'true' + corepack-install: + description: 'Install CorePack' + required: false + default: 'false' hard-link-nm: description: 'set nmMode to hardlinks-local in .yarnrc.yml' required: false @@ -42,6 +46,11 @@ runs: registry-url: https://npm.pkg.github.com scope: '@toeverything' + - name: Init CorePack + if: ${{ inputs.corepack-install == 'true' }} + shell: bash + run: corepack enable + - name: Set nmMode if: ${{ inputs.hard-link-nm == 'false' }} shell: bash diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 3d3f7bcb7..a8c976517 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -328,6 +328,7 @@ jobs: env: NODE_ENV: test DISTRIBUTION: web + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine services: postgres: image: postgres @@ -360,27 +361,13 @@ jobs: name: server-native.node path: ./packages/backend/server - - name: Initialize database - run: | - psql -h localhost -U postgres -c "CREATE DATABASE affine;" - psql -h localhost -U postgres -c "CREATE USER affine WITH PASSWORD 'affine';" - psql -h localhost -U postgres -c "ALTER USER affine WITH SUPERUSER;" - env: - PGPASSWORD: affine - - - name: Run init-db script - run: | - yarn workspace @affine/server exec prisma generate - yarn workspace @affine/server exec prisma db push - yarn workspace @affine/server data-migration run - env: - DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + - name: Prepare Server Test Environment + uses: ./.github/actions/server-test-env - name: Run server tests run: yarn workspace @affine/server test:coverage env: CARGO_TARGET_DIR: '${{ github.workspace }}/target' - DATABASE_URL: postgresql://affine:affine@localhost:5432/affine COPILOT_OPENAI_API_KEY: 'use_fake_openai_api_key' - name: Upload server test coverage results @@ -392,6 +379,133 @@ jobs: name: affine fail_ci_if_error: false + copilot-api-test: + name: Server Copilot Api Test + runs-on: ubuntu-latest + needs: + - build-server-native + env: + NODE_ENV: test + DISTRIBUTION: web + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + mailer: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + electron-install: false + full-cache: true + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/server + + - name: Prepare Server Test Environment + uses: ./.github/actions/server-test-env + + - name: Run server tests + run: yarn workspace @affine/server test:copilot:coverage --forbid-only + env: + CARGO_TARGET_DIR: '${{ github.workspace }}/target' + COPILOT_OPENAI_API_KEY: ${{ secrets.COPILOT_OPENAI_API_KEY }} + COPILOT_FAL_API_KEY: ${{ secrets.COPILOT_FAL_API_KEY }} + + - name: Upload server test coverage results + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/server/.coverage/lcov.info + flags: server-test + name: affine + fail_ci_if_error: false + + copilot-e2e-test: + name: Server Copilot E2E Test + runs-on: ubuntu-latest + env: + DISTRIBUTION: web + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + IN_CI_TEST: true + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3] + shardTotal: [3] + needs: + - build-server-native + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - uses: actions/checkout@v4 + + - name: Check blocksuite update + id: check-blocksuite-update + env: + BASE_REF: ${{ github.base_ref }} + run: | + if [ -n "$BASE_REF" ]; then + if node ./scripts/detect-blocksuite-update.mjs "$BASE_REF"; then + echo "skip=false" >> $GITHUB_OUTPUT + else + echo "skip=true" >> $GITHUB_OUTPUT + fi + else + echo "skip=true" >> $GITHUB_OUTPUT + fi + + - name: Setup Node.js + if: ${{ steps.check-blocksuite-update.outputs.skip == 'true' }} + uses: ./.github/actions/setup-node + with: + playwright-install: true + electron-install: false + hard-link-nm: false + + - name: Download server-native.node + if: ${{ steps.check-blocksuite-update.outputs.skip == 'true' }} + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/server + + - name: Run Copilot E2E Test ${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + if: ${{ steps.check-blocksuite-update.outputs.skip == 'true' }} + uses: ./.github/actions/copilot-test + with: + script: yarn workspace @affine-test/affine-cloud-copilot e2e --forbid-only --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + openai-key: ${{ secrets.COPILOT_OPENAI_API_KEY }} + fal-key: ${{ secrets.COPILOT_FAL_API_KEY }} + server-e2e-test: name: ${{ matrix.tests.name }} runs-on: ubuntu-latest @@ -458,19 +572,8 @@ jobs: name: affine.linux-x64-gnu.node path: ./packages/frontend/native - - name: Initialize database - run: | - psql -h localhost -U postgres -c "CREATE DATABASE affine;" - psql -h localhost -U postgres -c "CREATE USER affine WITH PASSWORD 'affine';" - psql -h localhost -U postgres -c "ALTER USER affine WITH SUPERUSER;" - env: - PGPASSWORD: affine - - - name: Run init-db script - run: | - yarn workspace @affine/server exec prisma generate - yarn workspace @affine/server exec prisma db push - yarn workspace @affine/server data-migration run + - name: Prepare Server Test Environment + uses: ./.github/actions/server-test-env - name: ${{ matrix.tests.name }} run: | @@ -633,6 +736,7 @@ jobs: - build-server-native - build-electron-renderer - server-test + - copilot-e2e-test - server-e2e-test - desktop-test - test-build-mobile-app diff --git a/.github/workflows/copilot-test-automatically.yml b/.github/workflows/copilot-test-automatically.yml new file mode 100644 index 000000000..b652c9091 --- /dev/null +++ b/.github/workflows/copilot-test-automatically.yml @@ -0,0 +1,29 @@ +name: Copilot Test Automatically + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+-canary.[0-9]+' + schedule: + - cron: '0 8 * * *' + workflow_dispatch: + +permissions: + actions: write + +jobs: + dispatch-test: + runs-on: ubuntu-latest + name: Setup Test + steps: + - name: dispatch test by tag + if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + uses: benc-uk/workflow-dispatch@v1 + with: + workflow: copilot-test.yml + - name: dispatch test by schedule + if: ${{ github.event_name == 'schedule' }} + uses: benc-uk/workflow-dispatch@v1 + with: + workflow: copilot-test.yml + ref: canary diff --git a/.github/workflows/copilot-test.yml b/.github/workflows/copilot-test.yml new file mode 100644 index 000000000..9c533d8c2 --- /dev/null +++ b/.github/workflows/copilot-test.yml @@ -0,0 +1,190 @@ +name: Copilot Cron Test + +on: + workflow_dispatch: + +env: + NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/node_modules/.cache/ms-playwright + +jobs: + build-server-native: + name: Build Server native + runs-on: ubuntu-latest + env: + CARGO_PROFILE_RELEASE_DEBUG: '1' + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: workspaces focus @affine/server-native + electron-install: false + - name: Build Rust + uses: ./.github/actions/build-rust + with: + target: 'x86_64-unknown-linux-gnu' + package: '@affine/server-native' + nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + - name: Upload server-native.node + uses: actions/upload-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/native/server-native.node + if-no-files-found: error + + copilot-api-test: + name: Server Copilot Api Test + runs-on: ubuntu-latest + needs: + - build-server-native + env: + NODE_ENV: test + DISTRIBUTION: web + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + mailer: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + playwright-install: true + electron-install: false + full-cache: true + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/server + + - name: Prepare Server Test Environment + uses: ./.github/actions/server-test-env + + - name: Run server tests + run: yarn workspace @affine/server test:copilot:coverage --forbid-only + env: + CARGO_TARGET_DIR: '${{ github.workspace }}/target' + COPILOT_OPENAI_API_KEY: ${{ secrets.COPILOT_OPENAI_API_KEY }} + COPILOT_FAL_API_KEY: ${{ secrets.COPILOT_FAL_API_KEY }} + + - name: Upload server test coverage results + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/server/.coverage/lcov.info + flags: server-test + name: affine + fail_ci_if_error: false + + copilot-e2e-test: + name: Server Copilot E2E Test + runs-on: ubuntu-latest + env: + DISTRIBUTION: web + DATABASE_URL: postgresql://affine:affine@localhost:5432/affine + IN_CI_TEST: true + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3] + shardTotal: [3] + needs: + - build-server-native + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: affine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + playwright-install: true + electron-install: false + hard-link-nm: false + + - name: Download server-native.node + uses: actions/download-artifact@v4 + with: + name: server-native.node + path: ./packages/backend/server + + - name: Run Copilot E2E Test ${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + uses: ./.github/actions/copilot-test + with: + script: yarn workspace @affine-test/affine-cloud-copilot e2e --forbid-only --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + openai-key: ${{ secrets.COPILOT_OPENAI_API_KEY }} + fal-key: ${{ secrets.COPILOT_FAL_API_KEY }} + + test-done: + needs: + - copilot-api-test + - copilot-e2e-test + if: always() + runs-on: ubuntu-latest + name: Post test result message + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Node.js + uses: ./.github/actions/setup-node + with: + extra-flags: 'workspaces focus @affine/copilot-result' + electron-install: false + - name: Post Success event to a Slack channel + if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} + run: node ./tools/copilot-result/index.js + env: + CHANNEL_ID: ${{ secrets.RELEASE_SLACK_CHNNEL_ID }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + BRANCH_SHA: ${{ github.sha }} + BRANCH_NAME: ${{ github.ref }} + COPILOT_RESULT: success + - name: Post Failed event to a Slack channel + id: failed-slack + if: ${{ always() && contains(needs.*.result, 'failure') }} + run: node ./tools/copilot-result/index.js + env: + CHANNEL_ID: ${{ secrets.RELEASE_SLACK_CHNNEL_ID }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + BRANCH_SHA: ${{ github.sha }} + BRANCH_NAME: ${{ github.ref }} + COPILOT_RESULT: failed + - name: Post Cancel event to a Slack channel + id: cancel-slack + if: ${{ always() && contains(needs.*.result, 'cancelled') && !contains(needs.*.result, 'failure') }} + run: node ./tools/copilot-result/index.js + env: + CHANNEL_ID: ${{ secrets.RELEASE_SLACK_CHNNEL_ID }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + BRANCH_SHA: ${{ github.sha }} + BRANCH_NAME: ${{ github.ref }} + COPILOT_RESULT: canceled diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 2c51208a5..75d464b9d 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -12,7 +12,9 @@ "start": "node --loader ts-node/esm/transpile-only.mjs ./src/index.ts", "dev": "nodemon ./src/index.ts", "test": "ava --concurrency 1 --serial", + "test:copilot": "ava --concurrency 1 --serial \"tests/**/copilot-*.e2e.ts\"", "test:coverage": "c8 ava --concurrency 1 --serial", + "test:copilot:coverage": "c8 ava --timeout=5m --concurrency 1 --serial \"tests/**/copilot-*.e2e.ts\"", "postinstall": "prisma generate", "data-migration": "node --loader ts-node/esm/transpile-only.mjs ./src/data/index.ts", "predeploy": "yarn prisma migrate deploy && node --import ./scripts/register.js ./dist/data/index.js run", diff --git a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts index 0299aa382..f2e613f16 100644 --- a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts +++ b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts @@ -522,7 +522,7 @@ Rules to follow: messages: [ { role: 'system', - content: `You are a creative blog writer specializing in producing captivating and informative content. Your task is to write a blog post based on the content provided by user in its original language. The blog post should be between 500-700 words, engaging, and well-structured, with an inviting introduction that hooks the reader, concise and informative body paragraphs, and a compelling conclusion that encourages readers to engage with the content, whether it's through commenting, sharing, or exploring the topics further. Please ensure the blog post is optimized for SEO with relevant keywords, includes at least 2-3 subheadings for better readability, and whenever possible, provides actionable insights or takeaways for the reader. Integrate a friendly and approachable tone throughout the post that reflects the voice of someone knowledgeable yet relatable. And ultimately output the content in Markdown format. Do not put everything into a single code block unless everything is code.`, + content: `You are a creative blog writer specializing in producing captivating and informative content. Your task is to write a blog post based on the content provided by user in its original language. The blog post should be between 500-700 words, engaging, and well-structured, with an inviting introduction that hooks the reader, concise and informative body paragraphs, and a compelling conclusion that encourages readers to engage with the content, whether it's through commenting, sharing, or exploring the topics further. Please ensure the blog post is optimized for SEO with relevant keywords, includes at least 2-3 subheadings for better readability, and whenever possible, provides actionable insights or takeaways for the reader. Integrate a friendly and approachable tone throughout the post that reflects the voice of someone knowledgeable yet relatable. And ultimately output the content in Markdown format. You should not place the entire article in a code block.`, }, { role: 'user', diff --git a/packages/backend/server/tests/copilot-provider.e2e.ts b/packages/backend/server/tests/copilot-provider.e2e.ts new file mode 100644 index 000000000..d3e82ad95 --- /dev/null +++ b/packages/backend/server/tests/copilot-provider.e2e.ts @@ -0,0 +1,492 @@ +/// + +import { TestingModule } from '@nestjs/testing'; +import type { ExecutionContext, TestFn } from 'ava'; +import ava from 'ava'; + +import { AuthService } from '../src/core/auth'; +import { QuotaModule } from '../src/core/quota'; +import { ConfigModule } from '../src/fundamentals/config'; +import { CopilotModule } from '../src/plugins/copilot'; +import { prompts, PromptService } from '../src/plugins/copilot/prompt'; +import { + CopilotProviderService, + FalProvider, + OpenAIProvider, + registerCopilotProvider, + unregisterCopilotProvider, +} from '../src/plugins/copilot/providers'; +import { + CopilotChatTextExecutor, + CopilotWorkflowService, + GraphExecutorState, +} from '../src/plugins/copilot/workflow'; +import { + CopilotChatImageExecutor, + CopilotCheckHtmlExecutor, + CopilotCheckJsonExecutor, +} from '../src/plugins/copilot/workflow/executor'; +import { createTestingModule } from './utils'; +import { TestAssets } from './utils/copilot'; + +type Tester = { + auth: AuthService; + module: TestingModule; + prompt: PromptService; + provider: CopilotProviderService; + workflow: CopilotWorkflowService; + executors: { + image: CopilotChatImageExecutor; + text: CopilotChatTextExecutor; + html: CopilotCheckHtmlExecutor; + json: CopilotCheckJsonExecutor; + }; +}; +const test = ava as TestFn; + +const isCopilotConfigured = + !!process.env.COPILOT_OPENAI_API_KEY && + !!process.env.COPILOT_FAL_API_KEY && + process.env.COPILOT_OPENAI_API_KEY !== '1' && + process.env.COPILOT_FAL_API_KEY !== '1'; +const runIfCopilotConfigured = test.macro( + async ( + t, + callback: (t: ExecutionContext) => Promise | void + ) => { + if (isCopilotConfigured) { + await callback(t); + } else { + t.log('Skip test because copilot is not configured'); + t.pass(); + } + } +); + +test.beforeEach(async t => { + const module = await createTestingModule({ + imports: [ + ConfigModule.forRoot({ + plugins: { + copilot: { + openai: { + apiKey: process.env.COPILOT_OPENAI_API_KEY, + }, + fal: { + apiKey: process.env.COPILOT_FAL_API_KEY, + }, + }, + }, + }), + QuotaModule, + CopilotModule, + ], + }); + + const auth = module.get(AuthService); + const prompt = module.get(PromptService); + const provider = module.get(CopilotProviderService); + const workflow = module.get(CopilotWorkflowService); + + t.context.module = module; + t.context.auth = auth; + t.context.prompt = prompt; + t.context.provider = provider; + t.context.workflow = workflow; + t.context.executors = { + image: module.get(CopilotChatImageExecutor), + text: module.get(CopilotChatTextExecutor), + html: module.get(CopilotCheckHtmlExecutor), + json: module.get(CopilotCheckJsonExecutor), + }; +}); + +test.beforeEach(async t => { + const { prompt, executors } = t.context; + + executors.image.register(); + executors.text.register(); + executors.html.register(); + executors.json.register(); + + registerCopilotProvider(OpenAIProvider); + registerCopilotProvider(FalProvider); + + for (const name of await prompt.listNames()) { + await prompt.delete(name); + } + + for (const p of prompts) { + await prompt.set(p.name, p.model, p.messages, p.config); + } +}); + +test.afterEach.always(async _ => { + unregisterCopilotProvider(OpenAIProvider.type); + unregisterCopilotProvider(FalProvider.type); +}); + +test.afterEach.always(async t => { + await t.context.module.close(); +}); + +const assertNotWrappedInCodeBlock = ( + t: ExecutionContext, + result: string +) => { + t.assert( + !result.replaceAll('\n', '').trim().startsWith('```') && + !result.replaceAll('\n', '').trim().endsWith('```'), + 'should not wrap in code block' + ); +}; + +const checkMDList = (text: string) => { + const lines = text.split('\n'); + const listItemRegex = /^( {2})*(-|\*|\+) .+$/; + let prevIndent = null; + + for (const line of lines) { + if (line.trim() === '') continue; + if (!listItemRegex.test(line)) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + const currentIndent = line.match(/^( *)/)?.[0].length!; + if (Number.isNaN(currentIndent) || currentIndent % 2 !== 0) { + return false; + } + + if (prevIndent !== null && currentIndent > 0) { + const indentDiff = currentIndent - prevIndent; + // allow 1 level of indentation difference + if (indentDiff > 2) { + return false; + } + } + + prevIndent = currentIndent; + } + + return true; +}; + +const checkUrl = (url: string) => { + try { + new URL(url); + return true; + } catch { + return false; + } +}; + +const retry = async ( + action: string, + t: ExecutionContext, + callback: (t: ExecutionContext) => void +) => { + let i = 3; + while (i--) { + const ret = await t.try(callback); + if (ret.passed) { + ret.commit(); + break; + } else { + ret.discard(); + t.log(ret.errors.map(e => e.message).join('\n')); + t.log(`retrying ${action} ${3 - i}/3 ...`); + } + } +}; + +// ==================== utils ==================== + +test('should validate markdown list', t => { + t.true( + checkMDList(` +- item 1 +- item 2 +`) + ); + t.true( + checkMDList(` +- item 1 + - item 1.1 +- item 2 +`) + ); + t.true( + checkMDList(` +- item 1 + - item 1.1 + - item 1.1.1 +- item 2 +`) + ); + t.true( + checkMDList(` +- item 1 + - item 1.1 + - item 1.1.1 + - item 1.1.2 +- item 2 +`) + ); + t.true( + checkMDList(` +- item 1 + - item 1.1 + - item 1.1.1 +- item 1.2 +`) + ); + t.false( + checkMDList(` +- item 1 + - item 1.1 + - item 1.1.1.1 +`) + ); +}); + +// ==================== action ==================== + +const actions = [ + { + promptName: [ + 'Summary', + 'Explain this', + 'Write an article about this', + 'Write a twitter about this', + 'Write a poem about this', + 'Write a blog post about this', + 'Write outline', + 'Change tone to', + 'Improve writing for it', + 'Improve grammar for it', + 'Fix spelling for it', + 'Create headings', + 'Make it longer', + 'Make it shorter', + 'Continue writing', + ], + messages: [{ role: 'user' as const, content: TestAssets.SSOT }], + verifier: (t: ExecutionContext, result: string) => { + assertNotWrappedInCodeBlock(t, result); + t.assert( + result.toLowerCase().includes('single source of truth'), + 'should include original keyword' + ); + }, + type: 'text' as const, + }, + { + promptName: ['Brainstorm ideas about this', 'Brainstorm mindmap'], + messages: [{ role: 'user' as const, content: TestAssets.SSOT }], + verifier: (t: ExecutionContext, result: string) => { + assertNotWrappedInCodeBlock(t, result); + t.assert(checkMDList(result), 'should be a markdown list'); + }, + type: 'text' as const, + }, + { + promptName: 'Expand mind map', + messages: [{ role: 'user' as const, content: '- Single source of truth' }], + verifier: (t: ExecutionContext, result: string) => { + assertNotWrappedInCodeBlock(t, result); + t.assert(checkMDList(result), 'should be a markdown list'); + }, + type: 'text' as const, + }, + { + promptName: 'Find action items from it', + messages: [{ role: 'user' as const, content: TestAssets.TODO }], + verifier: (t: ExecutionContext, result: string) => { + assertNotWrappedInCodeBlock(t, result); + t.assert(checkMDList(result), 'should be a markdown list'); + }, + type: 'text' as const, + }, + { + promptName: ['Explain this code', 'Check code error'], + messages: [{ role: 'user' as const, content: TestAssets.Code }], + verifier: (t: ExecutionContext, result: string) => { + assertNotWrappedInCodeBlock(t, result); + t.assert( + result.toLowerCase().includes('distance'), + 'explain code result should include keyword' + ); + }, + type: 'text' as const, + }, + { + promptName: 'Translate to', + messages: [ + { + role: 'user' as const, + content: TestAssets.SSOT, + params: { language: 'Simplified Chinese' }, + }, + ], + verifier: (t: ExecutionContext, result: string) => { + assertNotWrappedInCodeBlock(t, result); + t.assert( + result.toLowerCase().includes('单一事实来源'), + 'explain code result should include keyword' + ); + }, + type: 'text' as const, + }, + { + promptName: ['Generate a caption', 'Explain this image'], + messages: [ + { + role: 'user' as const, + content: '', + attachments: [ + 'https://cdn.affine.pro/copilot-test/Qgqy9qZT3VGIEuMIotJYoCCH.jpg', + ], + }, + ], + verifier: (t: ExecutionContext, result: string) => { + assertNotWrappedInCodeBlock(t, result); + const content = result.toLowerCase(); + t.assert( + content.includes('classroom') || + content.includes('school') || + content.includes('sky'), + 'explain code result should include keyword' + ); + }, + type: 'text' as const, + }, + { + promptName: [ + 'debug:action:fal-face-to-sticker', + 'debug:action:fal-remove-bg', + 'debug:action:fal-sd15', + 'debug:action:fal-upscaler', + ], + messages: [ + { + role: 'user' as const, + content: '', + attachments: [ + 'https://cdn.affine.pro/copilot-test/Zkas098lkjdf-908231.jpg', + ], + }, + ], + verifier: (t: ExecutionContext, link: string) => { + t.truthy(checkUrl(link), 'should be a valid url'); + }, + type: 'image' as const, + }, +]; +for (const { promptName, messages, verifier, type } of actions) { + const prompts = Array.isArray(promptName) ? promptName : [promptName]; + for (const promptName of prompts) { + test( + `should be able to run action: ${promptName}`, + runIfCopilotConfigured, + async t => { + const { provider: providerService, prompt: promptService } = t.context; + const prompt = (await promptService.get(promptName))!; + t.truthy(prompt, 'should have prompt'); + const provider = (await providerService.getProviderByModel( + prompt.model + ))!; + t.truthy(provider, 'should have provider'); + await retry(`action: ${promptName}`, t, async t => { + if (type === 'text' && 'generateText' in provider) { + const result = await provider.generateText( + [ + ...prompt.finish( + messages.reduce( + // @ts-expect-error + (acc, m) => Object.assign(acc, m.params), + {} + ) + ), + ...messages, + ], + prompt.model + ); + t.truthy(result, 'should return result'); + verifier?.(t, result); + } else if (type === 'image' && 'generateImages' in provider) { + const result = await provider.generateImages( + [ + ...prompt.finish( + messages.reduce( + // @ts-expect-error + (acc, m) => Object.assign(acc, m.params), + {} + ) + ), + ...messages, + ], + prompt.model + ); + t.truthy(result.length, 'should return result'); + for (const r of result) { + verifier?.(t, r); + } + } else { + t.fail('unsupported provider type'); + } + }); + } + ); + } +} + +// ==================== workflow ==================== + +const workflows = [ + { + name: 'brainstorm', + content: 'apple company', + verifier: (t: ExecutionContext, result: string) => { + t.assert(checkMDList(result), 'should be a markdown list'); + }, + }, + { + name: 'presentation', + content: 'apple company', + verifier: (t: ExecutionContext, result: string) => { + for (const l of result.split('\n')) { + t.notThrows(() => { + JSON.parse(l.trim()); + }, 'should be valid json'); + } + }, + }, +]; + +for (const { name, content, verifier } of workflows) { + test( + `should be able to run workflow: ${name}`, + runIfCopilotConfigured, + async t => { + const { workflow } = t.context; + + await retry(`workflow: ${name}`, t, async t => { + let result = ''; + for await (const ret of workflow.runGraph({ content }, name)) { + if (ret.status === GraphExecutorState.EnterNode) { + console.log('enter node:', ret.node.name); + } else if (ret.status === GraphExecutorState.ExitNode) { + console.log('exit node:', ret.node.name); + } else if (ret.status === GraphExecutorState.EmitAttachment) { + console.log('stream attachment:', ret); + } else { + result += ret.content; + } + } + t.truthy(result, 'should return result'); + verifier?.(t, result); + }); + } + ); +} diff --git a/packages/backend/server/tests/utils/copilot.ts b/packages/backend/server/tests/utils/copilot.ts index ea85b451e..8404e00dc 100644 --- a/packages/backend/server/tests/utils/copilot.ts +++ b/packages/backend/server/tests/utils/copilot.ts @@ -500,3 +500,9 @@ export const WorkflowTestCases: WorkflowTestCase[] = [ result: ['test'], }, ]; + +export const TestAssets = { + SSOT: `In [information science](https://en.wikipedia.org/wiki/Information_science) and [information technology](https://en.wikipedia.org/wiki/Information_technology), **single source of truth** (**SSOT**) architecture, or **single point of truth** (**SPOT**) architecture, for [information systems](https://en.wikipedia.org/wiki/Information_system) is the practice of structuring [information models](https://en.wikipedia.org/wiki/Information_model) and associated [data schemas](https://en.wikipedia.org/wiki/Database_schema) such that every [data element](https://en.wikipedia.org/wiki/Data_element) is [mastered](https://en.wikipedia.org/wiki/Golden_record_(informatics)) (or edited) in only one place, providing [data normalization to a canonical form](https://en.wikipedia.org/wiki/Canonical_form#Computing) (for example, in [database normalization](https://en.wikipedia.org/wiki/Database_normalization) or content [transclusion](https://en.wikipedia.org/wiki/Transclusion)).\n\nThere are several scenarios with respect to copies and updates:\n\n* The master data is never copied and instead only references to it are made; this means that all reads and updates go directly to the SSOT.\n* The master data is copied but the copies are only read and only the master data is updated; if requests to read data are only made on copies, this is an instance of [CQRS](https://en.wikipedia.org/wiki/CQRS).\n* The master data is copied and the copies are updated; this needs a reconciliation mechanism when there are concurrent updates.\n * Updates on copies can be thrown out whenever a concurrent update is made on the master, so they are not considered fully committed until propagated to the master. (many blockchains work that way.)\n * Concurrent updates are merged. (if an automatic merge fails, it could fall back on another strategy, which could be the previous strategy or something else like manual intervention, which most source version control systems do.)\n\nThe advantages of SSOT architectures include easier prevention of mistaken inconsistencies (such as a duplicate value/copy somewhere being forgotten), and greatly simplified [version control](https://en.wikipedia.org/wiki/Version_control). Without a SSOT, dealing with inconsistencies implies either complex and error-prone consensus algorithms, or using a simpler architecture that's liable to lose data in the face of inconsistency (the latter may seem unacceptable but it is sometimes a very good choice; it is how most blockchains operate: a transaction is actually final only if it was included in the next block that is mined).\n\nIdeally, SSOT systems provide data that are authentic (and [authenticatable](https://en.wikipedia.org/wiki/Authentication)), relevant, and [referable](https://en.wikipedia.org/wiki/Reference_(computer_science)).[[1]](https://en.wikipedia.org/wiki/Single_source_of_truth#cite_note-1)\n\nDeployment of an SSOT architecture is becoming increasingly important in enterprise settings where incorrectly linked duplicate or de-normalized data elements (a direct consequence of intentional or unintentional [denormalization](https://en.wikipedia.org/wiki/Denormalization) of any explicit data model) pose a risk for retrieval of outdated, and therefore incorrect, information. Common examples (i.e., example classes of implementation) are as follows:\n\n* In [electronic health records](https://en.wikipedia.org/wiki/Electronic_health_record) (EHRs), it is imperative to accurately validate patient identity against a single referential repository, which serves as the SSOT. Duplicate representations of data within the enterprise would be implemented by the use of [pointers](https://en.wikipedia.org/wiki/Pointer_(computer_programming)) rather than duplicate database tables, rows, or cells. This ensures that data updates to elements in the authoritative location are comprehensively distributed to all [federated database](https://en.wikipedia.org/wiki/Federated_database) constituencies in the larger overall [enterprise architecture](https://en.wikipedia.org/wiki/Enterprise_architecture). EHRs are an excellent class for exemplifying how SSOT architecture is both poignantly necessary and challenging to achieve: it is challenging because inter-organization [health information exchange](https://en.wikipedia.org/wiki/Health_information_exchange) is inherently a [cybersecurity](https://en.wikipedia.org/wiki/Computer_security) competence hurdle, and nonetheless it is necessary, to prevent [medical errors](https://en.wikipedia.org/wiki/Medical_error), to prevent the wasted costs of inefficiency (such as duplicated work or rework), and to make the [primary care](https://en.wikipedia.org/wiki/Primary_care) and [medical home](https://en.wikipedia.org/wiki/Medical_home) concepts feasible (to achieve competent [care transitions](https://en.wikipedia.org/wiki/Transitional_care)).\n* [Single-source publishing](https://en.wikipedia.org/wiki/Single-source_publishing) as a general principle or ideal in [content management](https://en.wikipedia.org/wiki/Content_management) relies on having SSOTs, via [transclusion](https://en.wikipedia.org/wiki/Transclusion) or (otherwise, at least) substitution. Substitution happens via [libraries of objects](https://en.wikipedia.org/wiki/Library_(computing)#Object_libraries) that can be propagated as static copies which are later refreshed when necessary (that is, when refreshing of the [copy-paste](https://en.wikipedia.org/wiki/Cut,_copy,_and_paste) or [import](https://en.wikipedia.org/wiki/Import_and_export_of_data) is triggered by a larger updating event). [Component content management systems](https://en.wikipedia.org/wiki/Component_content_management_system) are a class of [content management systems](https://en.wikipedia.org/wiki/Content_management_system) that aim to provide competence on this level.`, + Code: `fn euclidean_distance(a: &Vec, b: &Vec) -> f64 {\na.iter().zip(b.iter()).map(|(x, y)| (*x - *y).powi(2)).sum::().sqrt()\n}`, + TODO: 'The PDF exporting feature in edgeless is flawed, which is not supposed to support rendering content with infinite logical size. We should remove this feature entry to user, but the current "export blob in surface ref" feature should be migrated and kept (which is base on the edgelessToCanvas API, which makes sense for exporting a partial viewport area for the page)', +}; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/_common/components/chat-action-list.ts b/packages/frontend/core/src/blocksuite/presets/ai/_common/components/chat-action-list.ts index 974214310..70bde3011 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/_common/components/chat-action-list.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/_common/components/chat-action-list.ts @@ -153,6 +153,9 @@ export class ChatActionList extends LitElement { }); } }} + data-testid="action-${action.title + .toLowerCase() + .replaceAll(' ', '-')}" > ${action.title} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/_common/components/copy-more.ts b/packages/frontend/core/src/blocksuite/presets/ai/_common/components/copy-more.ts index dae309d4a..478e77fe5 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/_common/components/copy-more.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/_common/components/copy-more.ts @@ -178,13 +178,17 @@ export class ChatCopyMore extends WithDisposable(LitElement) { this._notifySuccess('Copied to clipboard'); } }} + data-testid="action-copy-button" > ${CopyIcon} Copy ` : nothing} ${isLast - ? html`
this.retry()}> + ? html`
this.retry()} + data-testid="action-retry-button" + > ${RetryIcon} Retry
` diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts index 48649e645..d9bd3ae30 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-input.ts @@ -371,6 +371,7 @@ export class ChatPanelInput extends WithDisposable(LitElement) { } } }} + data-testid="chat-panel-input" >
{ await this.cleanupHistories(); }} + data-testid="chat-panel-clear" > ${ChatClearIcon}
@@ -410,6 +412,7 @@ export class ChatPanelInput extends WithDisposable(LitElement) { @click="${this.send}" class="chat-panel-send" aria-disabled=${this.isInputEmpty} + data-testid="chat-panel-send" > ${ChatSendIcon}
`} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-messages.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-messages.ts index b9e2839bf..fbbd72a36 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-messages.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-messages.ts @@ -379,7 +379,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) { : html`
`}
` : AffineAvatarIcon} - ${isUser ? 'You' : 'AFFINE AI'} + ${isUser ? 'You' : 'AFFiNE AI'} `; } diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts index 37a6ef325..c21ceebc2 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts @@ -244,7 +244,7 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) { override render() { return html`
-
AFFINE AI
+
AFFiNE AI
{ AIProvider.toggleGeneralAIOnboarding?.(true); diff --git a/packages/frontend/core/src/modules/workbench/view/route-container.tsx b/packages/frontend/core/src/modules/workbench/view/route-container.tsx index 30a2447c1..4ecbbe13c 100644 --- a/packages/frontend/core/src/modules/workbench/view/route-container.tsx +++ b/packages/frontend/core/src/modules/workbench/view/route-container.tsx @@ -34,6 +34,7 @@ const ToggleButton = ({ onClick={onToggle} className={className} data-show={show} + data-testid="right-sidebar-toggle" > diff --git a/packages/frontend/core/src/modules/workbench/view/sidebar/sidebar-container.tsx b/packages/frontend/core/src/modules/workbench/view/sidebar/sidebar-container.tsx index 7f2694f51..a1c3056c6 100644 --- a/packages/frontend/core/src/modules/workbench/view/sidebar/sidebar-container.tsx +++ b/packages/frontend/core/src/modules/workbench/view/sidebar/sidebar-container.tsx @@ -40,6 +40,7 @@ export const SidebarContainer = ({ styles.sidebarBodyTarget, !BUILD_CONFIG.isElectron && styles.borderTop )} + data-testid={`sidebar-tab-content-${sidebar.id}`} /> )) ) : ( diff --git a/packages/frontend/core/src/modules/workbench/view/sidebar/sidebar-header-switcher.tsx b/packages/frontend/core/src/modules/workbench/view/sidebar/sidebar-header-switcher.tsx index 179130e92..fe15d30cf 100644 --- a/packages/frontend/core/src/modules/workbench/view/sidebar/sidebar-header-switcher.tsx +++ b/packages/frontend/core/src/modules/workbench/view/sidebar/sidebar-header-switcher.tsx @@ -22,6 +22,7 @@ export const SidebarHeaderSwitcher = () => { tabId={tab.id} /> ), + testId: `sidebar-tab-${tab.id}`, style: { padding: 0, fontSize: 20, width: 24 }, })); diff --git a/scripts/detect-blocksuite-update.mjs b/scripts/detect-blocksuite-update.mjs new file mode 100644 index 000000000..7f3d11df9 --- /dev/null +++ b/scripts/detect-blocksuite-update.mjs @@ -0,0 +1,105 @@ +const { exec } = await import('node:child_process'); +const { fileURLToPath } = await import('node:url'); +const { readdir } = await import('node:fs/promises'); +const { join } = await import('node:path'); + +async function readJsonFromCommit(commit, file) { + function readFile(commit, file) { + return new Promise((resolve, reject) => { + exec(`git show ${commit}:${file}`, (error, stdout, stderr) => { + if (error) { + reject(stderr); + } else { + resolve(stdout); + } + }); + }); + } + + try { + const content = await readFile(commit, file); + return JSON.parse(content); + } catch {} +} + +function checkBlocksuiteChanged(oldPkg, newPkg) { + const changed = new Set(); + const keys = ['dependencies', 'devDependencies']; + + keys.forEach(key => { + const oldDeps = oldPkg[key] || {}; + const newDeps = newPkg[key] || {}; + + Object.keys(newDeps).forEach(dep => { + if (newDeps[dep] !== oldDeps[dep] && dep.startsWith('@blocksuite/')) { + changed.add(dep); + } + }); + }); + + return changed; +} + +async function findPackageJson(root) { + const packages = new Set(); + + async function walk(dir) { + const files = await readdir(dir, { withFileTypes: true }); + + for (const file of files) { + if (file.isDirectory() && file.name !== 'node_modules') { + await walk(join(dir, file.name)); + } else if (file.name === 'package.json') { + let path = join(dir.replace(root, ''), file.name); + if (path.startsWith('/')) { + path = path.slice(1); + } + packages.add(path); + } + } + } + + await walk(root); + + return packages; +} + +async function main() { + if (process.argv.length < 3) { + console.error('Usage: node script.js '); + process.exit(1); + } + + const commitHash = process.argv[2]; + const currentHead = process.argv[3] || 'HEAD'; + const changedPackages = new Set(); + const folders = await findPackageJson( + join(fileURLToPath(import.meta.url), '..', '..') + ); + + for (const packagePath of folders) { + const old = await readJsonFromCommit(commitHash, packagePath); + const current = await readJsonFromCommit(currentHead, packagePath); + console.log('checking:', packagePath); + if ( + old && + current && + typeof old === 'object' && + typeof current === 'object' + ) { + for (const p of checkBlocksuiteChanged(old, current)) { + changedPackages.add(p); + } + } + } + + if (changedPackages.size > 0) { + console.log('Blocksuite packages have been updated.', changedPackages); + process.exit(1); + } else { + console.log('No changes to Blocksuite packages.'); + process.exit(0); + } +} + +main().catch(console.error); diff --git a/tests/affine-cloud-copilot/e2e/copilot.spec.ts b/tests/affine-cloud-copilot/e2e/copilot.spec.ts new file mode 100644 index 000000000..1b303be25 --- /dev/null +++ b/tests/affine-cloud-copilot/e2e/copilot.spec.ts @@ -0,0 +1,591 @@ +import { test } from '@affine-test/kit/playwright'; +import { + createRandomAIUser, + loginUser, + loginUserDirectly, +} from '@affine-test/kit/utils/cloud'; +import { openHomePage, setCoreUrl } from '@affine-test/kit/utils/load-page'; +import { + clickNewPageButton, + getBlockSuiteEditorTitle, + waitForEditorLoad, +} from '@affine-test/kit/utils/page-logic'; +import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar'; +import { createLocalWorkspace } from '@affine-test/kit/utils/workspace'; +import { expect, type Page } from '@playwright/test'; + +test.describe.configure({ mode: 'parallel' }); + +const ONE_SECOND = 1000; +const TEN_SECONDS = 10 * ONE_SECOND; +const ONE_MINUTE = 60 * ONE_SECOND; + +let isProduction = process.env.NODE_ENV === 'production'; +if ( + process.env.PLAYWRIGHT_USER_AGENT && + process.env.PLAYWRIGHT_EMAIL && + !process.env.PLAYWRIGHT_PASSWORD +) { + test.use({ + userAgent: process.env.PLAYWRIGHT_USER_AGENT || 'affine-tester', + }); + setCoreUrl(process.env.PLAYWRIGHT_CORE_URL || 'http://localhost:8080'); + isProduction = true; +} + +function getUser() { + if ( + !isProduction || + !process.env.PLAYWRIGHT_EMAIL || + !process.env.PLAYWRIGHT_PASSWORD + ) { + return createRandomAIUser(); + } + + return { + email: process.env.PLAYWRIGHT_EMAIL, + password: process.env.PLAYWRIGHT_PASSWORD, + }; +} + +test.skip( + () => + !process.env.COPILOT_OPENAI_API_KEY || + !process.env.COPILOT_FAL_API_KEY || + process.env.COPILOT_OPENAI_API_KEY === '1' || + process.env.COPILOT_FAL_API_KEY === '1', + 'skip test if no copilot api key' +); + +test('can open chat side panel', async ({ page }) => { + await openHomePage(page); + await waitForEditorLoad(page); + await clickNewPageButton(page); + await page.getByTestId('right-sidebar-toggle').click({ + delay: 200, + }); + await page.getByTestId('sidebar-tab-chat').click(); + await expect(page.getByTestId('sidebar-tab-content-chat')).toBeVisible(); +}); + +const makeChat = async (page: Page, content: string) => { + if (await page.getByTestId('sidebar-tab-chat').isHidden()) { + await page.getByTestId('right-sidebar-toggle').click({ + delay: 200, + }); + } + await page.getByTestId('sidebar-tab-chat').click(); + await page.getByTestId('chat-panel-input').focus(); + await page.keyboard.type(content); + await page.keyboard.press('Enter'); +}; + +const clearChat = async (page: Page) => { + await page.getByTestId('chat-panel-clear').click(); + await page.getByTestId('confirm-modal-confirm').click(); +}; + +const collectChat = async (page: Page) => { + const chatPanel = await page.waitForSelector('.chat-panel-messages'); + if (await chatPanel.$('.chat-panel-messages-placeholder')) { + return []; + } + // wait ai response + await page.waitForSelector('.chat-panel-messages .message chat-copy-more'); + const lastMessage = await chatPanel.$$('.message').then(m => m[m.length - 1]); + await lastMessage.waitForSelector('chat-copy-more'); + await page.waitForTimeout(ONE_SECOND); + return Promise.all( + Array.from(await chatPanel.$$('.message')).map(async m => ({ + name: await m.$('.user-info').then(i => i?.innerText()), + content: await m + .$('chat-text') + .then(t => t?.$('editor-host')) + .then(e => e?.innerText()), + })) + ); +}; + +const focusToEditor = async (page: Page) => { + const title = getBlockSuiteEditorTitle(page); + await title.focus(); + await page.keyboard.press('Enter'); +}; + +const getEditorContent = async (page: Page) => { + const lines = await page.$$('page-editor .inline-editor'); + const contents = await Promise.all(lines.map(el => el.innerText())); + return ( + contents + // cleanup zero width space + .map(c => c.replace(/\u200B/g, '').trim()) + .filter(c => !!c) + .join('\n') + ); +}; + +const switchToEdgelessMode = async (page: Page) => { + const editor = await page.waitForSelector('page-editor'); + await page.getByTestId('switch-edgeless-mode-button').click(); + // wait for new editor + editor.waitForElementState('hidden'); + await page.waitForSelector('edgeless-editor'); +}; + +test('can trigger login at chat side panel', async ({ page }) => { + await openHomePage(page); + await waitForEditorLoad(page); + await clickNewPageButton(page); + await makeChat(page, 'hello'); + const loginTips = await page.waitForSelector('ai-error-wrapper'); + expect(await loginTips.innerText()).toBe('Login'); +}); + +test('can chat after login at chat side panel', async ({ page }) => { + await openHomePage(page); + await waitForEditorLoad(page); + await clickNewPageButton(page); + await makeChat(page, 'hello'); + const loginTips = await page.waitForSelector('ai-error-wrapper'); + (await loginTips.$('div'))!.click(); + // login + const user = await getUser(); + await loginUserDirectly(page, user); + // after login + await makeChat(page, 'hello'); + const history = await collectChat(page); + expect(history[0]).toEqual({ name: 'You', content: 'hello' }); + expect(history[1].name).toBe('AFFiNE AI'); +}); + +test.describe('chat panel', () => { + let user: { + email: string; + password: string; + }; + + test.beforeEach(async ({ page }) => { + user = await getUser(); + await loginUser(page, user); + }); + + test('basic chat', async ({ page }) => { + await page.reload(); + await clickSideBarAllPageButton(page); + await page.waitForTimeout(200); + await createLocalWorkspace({ name: 'test' }, page); + await clickNewPageButton(page); + await makeChat(page, 'hello'); + const history = await collectChat(page); + expect(history[0]).toEqual({ name: 'You', content: 'hello' }); + expect(history[1].name).toBe('AFFiNE AI'); + await clearChat(page); + expect((await collectChat(page)).length).toBe(0); + }); + + test('chat actions', async ({ page }) => { + await page.reload(); + await clickSideBarAllPageButton(page); + await page.waitForTimeout(200); + await createLocalWorkspace({ name: 'test' }, page); + await clickNewPageButton(page); + await makeChat(page, 'hello'); + const content = (await collectChat(page))[1].content; + await page.getByTestId('action-copy-button').click(); + await page.waitForTimeout(500); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe( + content + ); + await page.getByTestId('action-retry-button').click(); + expect((await collectChat(page))[1].content).not.toBe(content); + }); + + test('can be insert below', async ({ page }) => { + await page.reload(); + await clickSideBarAllPageButton(page); + await page.waitForTimeout(200); + await createLocalWorkspace({ name: 'test' }, page); + await clickNewPageButton(page); + await makeChat(page, 'hello'); + const content = (await collectChat(page))[1].content; + await focusToEditor(page); + // insert below + await page.getByTestId('action-insert-below').click(); + await page.waitForSelector('affine-format-bar-widget editor-toolbar'); + const editorContent = await getEditorContent(page); + expect(editorContent).toBe(content); + }); + + test('can be add to edgeless as node', async ({ page }) => { + await page.reload(); + await clickSideBarAllPageButton(page); + await page.waitForTimeout(200); + await createLocalWorkspace({ name: 'test' }, page); + await clickNewPageButton(page); + await makeChat(page, 'hello'); + const content = (await collectChat(page))[1].content; + await switchToEdgelessMode(page); + // delete default note + await (await page.waitForSelector('affine-edgeless-note')).click(); + page.keyboard.press('Delete'); + // insert note + await page.getByTestId('action-add-to-edgeless-as-note').click(); + const edgelessNode = await page.waitForSelector('affine-edgeless-note'); + expect(await edgelessNode.innerText()).toBe(content); + }); + + test('can be create as a doc', async ({ page }) => { + await page.reload(); + await clickSideBarAllPageButton(page); + await page.waitForTimeout(200); + await createLocalWorkspace({ name: 'test' }, page); + await clickNewPageButton(page); + await makeChat(page, 'hello'); + const content = (await collectChat(page))[1].content; + const editor = await page.waitForSelector('page-editor'); + await page.getByTestId('action-create-as-a-doc').click(); + // wait for new editor + editor.waitForElementState('hidden'); + await page.waitForSelector('page-editor'); + const editorContent = await getEditorContent(page); + expect(editorContent).toBe(content); + }); + + // feature not launched yet + test.skip('can be save chat to block', async ({ page }) => { + await page.reload(); + await clickSideBarAllPageButton(page); + await page.waitForTimeout(200); + await createLocalWorkspace({ name: 'test' }, page); + await clickNewPageButton(page); + await makeChat(page, 'hello'); + const contents = (await collectChat(page)).map(m => m.content); + await switchToEdgelessMode(page); + await page.getByTestId('action-save-chat-to-block').click(); + const chatBlock = await page.waitForSelector('affine-edgeless-ai-chat'); + expect( + await Promise.all( + (await chatBlock.$$('.ai-chat-user-message')).map(m => m.innerText()) + ) + ).toBe(contents); + }); + + test('can be chat and insert below in page mode', async ({ page }) => { + await page.reload(); + await clickSideBarAllPageButton(page); + await page.waitForTimeout(200); + await createLocalWorkspace({ name: 'test' }, page); + await clickNewPageButton(page); + await focusToEditor(page); + await page.keyboard.type('/'); + await page.getByTestId('sub-menu-0').getByText('Ask AI').click(); + const input = await page.waitForSelector('ai-panel-input textarea'); + await input.fill('hello'); + await input.press('Enter'); + const resp = await page.waitForSelector( + 'ai-panel-answer .response-list-container' + ); // wait response + const content = await ( + await page.waitForSelector('ai-panel-answer editor-host') + ).innerText(); + await (await resp.waitForSelector('.ai-item-insert-below')).click(); + const editorContent = await getEditorContent(page); + expect(editorContent).toBe(content); + }); + + test('can be retry or discard chat in page mode', async ({ page }) => { + await page.reload(); + await clickSideBarAllPageButton(page); + await page.waitForTimeout(200); + await createLocalWorkspace({ name: 'test' }, page); + await clickNewPageButton(page); + await focusToEditor(page); + await page.keyboard.type('/'); + await page.getByTestId('sub-menu-0').getByText('Ask AI').click(); + const input = await page.waitForSelector('ai-panel-input textarea'); + await input.fill('hello'); + await input.press('Enter'); + // regenerate + { + const resp = await page.waitForSelector( + 'ai-panel-answer .response-list-container:last-child' + ); + const answerEditor = await page.waitForSelector( + 'ai-panel-answer editor-host' + ); + const content = await answerEditor.innerText(); + await (await resp.waitForSelector('.ai-item-regenerate')).click(); + await page.waitForSelector('ai-panel-answer .response-list-container'); // wait response + expect( + await ( + await ( + await page.waitForSelector('ai-panel-answer') + ).waitForSelector('editor-host') + ).innerText() + ).not.toBe(content); + } + + // discard + { + const resp = await page.waitForSelector( + 'ai-panel-answer .response-list-container:last-child' + ); + await (await resp.waitForSelector('.ai-item-discard')).click(); + await page.getByTestId('confirm-modal-confirm').click(); + const editorContent = await getEditorContent(page); + expect(editorContent).toBe(''); + } + }); +}); + +test.describe('chat with block', () => { + let user: { + email: string; + password: string; + }; + + test.beforeEach(async ({ page }) => { + user = await getUser(); + await loginUser(page, user); + }); + + const collectTextAnswer = async (page: Page) => { + // wait ai response + await page.waitForSelector( + 'affine-ai-panel-widget .response-list-container', + { timeout: ONE_MINUTE } + ); + const answer = await page.waitForSelector( + 'affine-ai-panel-widget ai-panel-answer editor-host' + ); + return answer.innerText(); + }; + + const collectImageAnswer = async (page: Page, timeout = TEN_SECONDS) => { + // wait ai response + await page.waitForSelector( + 'affine-ai-panel-widget .response-list-container', + { timeout } + ); + const answer = await page.waitForSelector( + 'affine-ai-panel-widget .ai-answer-image img' + ); + return answer.getAttribute('src'); + }; + + const disableEditorBlank = async (page: Page) => { + // hide blank element, this may block the click + const blank = page.getByTestId('page-editor-blank'); + if (await blank.isVisible()) { + await blank.evaluate(node => (node.style.pointerEvents = 'none')); + } else { + console.warn('blank element not found'); + } + }; + + test.describe('chat with text', () => { + const pasteTextToPageEditor = async (page: Page, content: string) => { + await focusToEditor(page); + await page.keyboard.type(content); + }; + + test.beforeEach(async ({ page }) => { + await page.reload(); + await clickSideBarAllPageButton(page); + await page.waitForTimeout(200); + await createLocalWorkspace({ name: 'test' }, page); + await clickNewPageButton(page); + await pasteTextToPageEditor(page, 'hello'); + }); + + test.beforeEach(async ({ page }) => { + await page.waitForSelector('affine-paragraph').then(i => i.click()); + await page.keyboard.press('ControlOrMeta+A'); + await page + .waitForSelector('page-editor editor-toolbar ask-ai-button') + .then(b => b.click()); + }); + + const options = [ + // review with ai + 'Fix spelling', + 'Fix Grammar', + // edit with ai + 'Explain selection', + 'Improve writing', + 'Make it longer', + 'Make it shorter', + 'Continue writing', + // generate with ai + 'Summarize', + 'Generate headings', + 'Generate outline', + 'Find actions', + // draft with ai + 'Write an article about this', + 'Write a tweet about this', + 'Write a poem about this', + 'Write a blog post about this', + 'Brainstorm ideas about this', + ]; + for (const option of options) { + test(option, async ({ page }) => { + await disableEditorBlank(page); + await page + .waitForSelector( + `.ai-item-${option.replaceAll(' ', '-').toLowerCase()}` + ) + .then(i => i.click()); + expect(await collectTextAnswer(page)).toBeTruthy(); + }); + } + }); + + test.describe('chat with image block', () => { + const pasteImageToPageEditor = async (page: Page) => { + await page.evaluate(async () => { + const canvas = document.createElement('canvas'); + canvas.width = 100; + canvas.height = 100; + const blob = await new Promise(resolve => + canvas.toBlob(blob => resolve(blob!), 'image/png') + ); + await navigator.clipboard.write([ + new ClipboardItem({ [blob.type]: blob }), + ]); + }); + await focusToEditor(page); + await page.keyboard.press('ControlOrMeta+V'); + }; + + test.beforeEach(async ({ page }) => { + await page.reload(); + await clickSideBarAllPageButton(page); + await page.waitForTimeout(200); + await createLocalWorkspace({ name: 'test' }, page); + await clickNewPageButton(page); + await pasteImageToPageEditor(page); + }); + + test.describe('page mode', () => { + test.beforeEach(async ({ page }) => { + await disableEditorBlank(page); + await page.waitForSelector('affine-image').then(i => i.click()); + await page + .waitForSelector('affine-image editor-toolbar ask-ai-button') + .then(b => b.click()); + }); + + test('explain this image', async ({ page }) => { + await page + .waitForSelector('.ai-item-explain-this-image') + .then(i => i.click()); + expect(await collectTextAnswer(page)).toBeTruthy(); + }); + + test('generate a caption', async ({ page }) => { + await page + .waitForSelector('.ai-item-generate-a-caption') + .then(i => i.click()); + expect(await collectTextAnswer(page)).toBeTruthy(); + }); + + test('continue with ai', async ({ page }) => { + await page + .waitForSelector('.ai-item-continue-with-ai') + .then(i => i.click()); + await page + .waitForSelector('chat-panel-input .chat-panel-images') + .then(el => el.waitForElementState('visible')); + }); + + test('open ai chat', async ({ page }) => { + await page + .waitForSelector('.ai-item-open-ai-chat') + .then(i => i.click()); + const cards = await page.waitForSelector('chat-panel chat-cards'); + await cards.waitForElementState('visible'); + const cardTitles = await Promise.all( + await cards + .$$('.card-wrapper .card-title') + .then(els => els.map(async el => await el.innerText())) + ); + expect(cardTitles).toContain('Start with this Image'); + }); + }); + + // TODO(@darkskygit): block by BS-1709, enable this after bug fix + test.describe.skip('edgeless mode', () => { + test.beforeEach(async ({ page }) => { + await switchToEdgelessMode(page); + const note = await page.waitForSelector('affine-edgeless-note'); + + { + // move note to avoid menu overlap + const box = (await note.boundingBox())!; + page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + page.mouse.down(); + // sleep to avoid flicker + await page.waitForTimeout(500); + page.mouse.move(box.x + box.width / 2, box.y + box.height / 2 - 200); + await page.waitForTimeout(500); + page.mouse.up(); + note.click(); + } + + await disableEditorBlank(page); + await page.waitForSelector('affine-image').then(i => i.click()); + await page + .waitForSelector('affine-image editor-toolbar ask-ai-button') + .then(b => b.click()); + }); + + // skip by default, dalle is very slow + test.skip('generate an image', async ({ page }) => { + await page + .waitForSelector('.ai-item-generate-an-image') + .then(i => i.click()); + await page.keyboard.type('a cat'); + await page.keyboard.press('Enter'); + expect(await collectImageAnswer(page)).toBeTruthy(); + }); + + const processes = [ + 'Clearer', + 'Remove background', + // skip by default, need a face in image + // 'Convert to sticker', + ]; + for (const process of processes) { + test(`image processing ${process}`, async ({ page }) => { + await page + .waitForSelector('.ai-item-image-processing') + .then(i => i.hover()); + await page.getByText(process).click(); + { + // to be remove + await page.keyboard.type(','); + await page.keyboard.press('Enter'); + } + expect(await collectImageAnswer(page, ONE_MINUTE * 2)).toBeTruthy(); + }); + } + + const filters = ['Clay', 'Sketch', 'Anime', 'Pixel']; + for (const filter of filters) { + test(`ai image ${filter.toLowerCase()} filter`, async ({ page }) => { + await page + .waitForSelector('.ai-item-ai-image-filter') + .then(i => i.hover()); + await page.getByText(`${filter} style`).click(); + { + // to be remove + await page.keyboard.type(','); + await page.keyboard.press('Enter'); + } + expect(await collectImageAnswer(page, ONE_MINUTE * 2)).toBeTruthy(); + }); + } + }); + }); +}); diff --git a/tests/affine-cloud-copilot/package.json b/tests/affine-cloud-copilot/package.json new file mode 100644 index 000000000..dd4ba30c7 --- /dev/null +++ b/tests/affine-cloud-copilot/package.json @@ -0,0 +1,12 @@ +{ + "name": "@affine-test/affine-cloud-copilot", + "private": true, + "scripts": { + "e2e": "yarn playwright test" + }, + "devDependencies": { + "@affine-test/kit": "workspace:*", + "@playwright/test": "=1.47.2" + }, + "version": "0.17.0" +} diff --git a/tests/affine-cloud-copilot/playwright.config.ts b/tests/affine-cloud-copilot/playwright.config.ts new file mode 100644 index 000000000..712274c68 --- /dev/null +++ b/tests/affine-cloud-copilot/playwright.config.ts @@ -0,0 +1,79 @@ +import { testResultDir } from '@affine-test/kit/playwright'; +import type { + PlaywrightTestConfig, + PlaywrightWorkerOptions, +} from '@playwright/test'; + +const config: PlaywrightTestConfig = { + testDir: './e2e', + fullyParallel: !process.env.CI, + timeout: 120_000, + outputDir: testResultDir, + use: { + baseURL: 'http://localhost:8080/', + browserName: + (process.env.BROWSER as PlaywrightWorkerOptions['browserName']) ?? + 'chromium', + permissions: ['clipboard-read', 'clipboard-write'], + viewport: { width: 1440, height: 800 }, + actionTimeout: 10 * 1000, + locale: 'en-US', + trace: 'on', + video: 'on', + }, + forbidOnly: !!process.env.CI, + workers: 4, + retries: 3, + reporter: process.env.CI ? 'github' : 'list', + webServer: [ + { + command: 'yarn run serve:test-static', + port: 8081, + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + env: { + COVERAGE: process.env.COVERAGE || 'false', + ENABLE_DEBUG_PAGE: '1', + }, + }, + // Intentionally not building the web, reminds you to run it by yourself. + { + command: 'yarn -T run start:web-static', + port: 8080, + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + env: { + COVERAGE: process.env.COVERAGE || 'false', + }, + }, + { + command: 'yarn workspace @affine/server start', + port: 3010, + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + env: { + DATABASE_URL: + process.env.DATABASE_URL ?? + 'postgresql://affine:affine@localhost:5432/affine', + NODE_ENV: 'development', + AFFINE_ENV: process.env.AFFINE_ENV ?? 'dev', + DEBUG: 'affine:*', + FORCE_COLOR: 'true', + DEBUG_COLORS: 'true', + MAILER_HOST: '0.0.0.0', + MAILER_PORT: '1025', + MAILER_SENDER: 'noreply@toeverything.info', + MAILER_USER: 'noreply@toeverything.info', + MAILER_PASSWORD: 'affine', + }, + }, + ], +}; + +if (process.env.CI) { + config.retries = 3; +} + +export default config; diff --git a/tests/affine-cloud-copilot/tsconfig.json b/tests/affine-cloud-copilot/tsconfig.json new file mode 100644 index 000000000..828052ad7 --- /dev/null +++ b/tests/affine-cloud-copilot/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "esModuleInterop": true, + "outDir": "lib" + }, + "include": ["e2e"], + "references": [ + { + "path": "../../tests/kit" + }, + { + "path": "../../tests/fixtures" + } + ] +} diff --git a/tests/affine-cloud/playwright.config.ts b/tests/affine-cloud/playwright.config.ts index 0f705382f..e0f7ad44a 100644 --- a/tests/affine-cloud/playwright.config.ts +++ b/tests/affine-cloud/playwright.config.ts @@ -22,8 +22,8 @@ const config: PlaywrightTestConfig = { video: 'on', }, forbidOnly: !!process.env.CI, - workers: process.env.CI ? 1 : 4, - retries: 1, + workers: process.env.CI && !process.env.COPILOT ? 1 : 4, + retries: process.env.COPILOT ? 1 : 3, reporter: process.env.CI ? 'github' : 'list', webServer: [ { diff --git a/tests/affine-local/e2e/all-page.spec.ts b/tests/affine-local/e2e/all-page.spec.ts index 5d3692bf0..7c6f40016 100644 --- a/tests/affine-local/e2e/all-page.spec.ts +++ b/tests/affine-local/e2e/all-page.spec.ts @@ -16,32 +16,14 @@ import { openHomePage } from '@affine-test/kit/utils/load-page'; import { clickNewPageButton, clickPageMoreActions, + getAllPage, getBlockSuiteEditorTitle, waitForAllPagesLoad, waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar'; -import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; -function getAllPage(page: Page) { - const newPageButton = page.getByTestId('new-page-button-trigger'); - const newPageDropdown = newPageButton.locator('svg'); - const edgelessBlockCard = page.getByTestId('new-edgeless-button-in-all-page'); - - async function clickNewPageButton() { - const newPageButton = page.getByTestId('new-page-button-trigger'); - return await newPageButton.click(); - } - - async function clickNewEdgelessDropdown() { - await newPageDropdown.click(); - await edgelessBlockCard.click(); - } - - return { clickNewPageButton, clickNewEdgelessDropdown }; -} - test('all page', async ({ page }) => { await openHomePage(page); await waitForEditorLoad(page); diff --git a/tests/kit/utils/cloud.ts b/tests/kit/utils/cloud.ts index 9e4c70a16..90f8931bc 100644 --- a/tests/kit/utils/cloud.ts +++ b/tests/kit/utils/cloud.ts @@ -150,6 +150,68 @@ export async function createRandomUser(): Promise<{ } as any; } +export async function createRandomAIUser(): Promise<{ + name: string; + email: string; + password: string; + id: string; +}> { + const user = { + name: faker.internet.userName(), + email: faker.internet.email().toLowerCase(), + password: '123456', + }; + const result = await runPrisma(async client => { + const freeFeatureId = await client.feature + .findFirst({ + where: { feature: 'free_plan_v1' }, + select: { id: true }, + orderBy: { version: 'desc' }, + }) + .then(f => f!.id); + const aiFeatureId = await client.feature + .findFirst({ + where: { feature: 'unlimited_copilot' }, + select: { id: true }, + orderBy: { version: 'desc' }, + }) + .then(f => f!.id); + + await client.user.create({ + data: { + ...user, + emailVerifiedAt: new Date(), + password: await hash(user.password), + features: { + create: [ + { + reason: 'created by test case', + activated: true, + featureId: freeFeatureId, + }, + { + reason: 'created by test case', + activated: true, + featureId: aiFeatureId, + }, + ], + }, + }, + }); + + return await client.user.findUnique({ + where: { + email: user.email, + }, + }); + }); + cloudUserSchema.parse(result); + return { + ...result, + password: user.password, + } as any; +} + export async function deleteUser(email: string) { await runPrisma(async client => { await client.user.delete({ @@ -178,7 +240,24 @@ export async function loginUser( } await clickSideBarCurrentWorkspaceBanner(page); - await page.getByTestId('cloud-signin-button').click(); + await page.getByTestId('cloud-signin-button').click({ + delay: 200, + }); + await loginUserDirectly(page, user, config); +} + +export async function loginUserDirectly( + page: Page, + user: { + email: string; + password: string; + }, + config?: { + isElectron?: boolean; + beforeLogin?: () => Promise; + afterLogin?: () => Promise; + } +) { await page.getByPlaceholder('Enter your email address').fill(user.email); await page.getByTestId('continue-login-button').click({ delay: 200, @@ -188,8 +267,10 @@ export async function loginUser( await config.beforeLogin(); } await page.waitForTimeout(200); - await page.getByTestId('sign-in-button').click(); - await page.waitForTimeout(500); + const signIn = page.getByTestId('sign-in-button'); + await signIn.click(); + await signIn.waitFor({ state: 'detached' }); + await page.waitForTimeout(200); if (config?.afterLogin) { await config.afterLogin(); } diff --git a/tests/kit/utils/load-page.ts b/tests/kit/utils/load-page.ts index eba8111d8..5367119de 100644 --- a/tests/kit/utils/load-page.ts +++ b/tests/kit/utils/load-page.ts @@ -1,7 +1,11 @@ import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; -export const coreUrl = 'http://localhost:8080'; +export let coreUrl = 'http://localhost:8080'; + +export function setCoreUrl(url: string) { + coreUrl = url; +} export async function openHomePage(page: Page) { await page.goto(coreUrl); diff --git a/tests/kit/utils/page-logic.ts b/tests/kit/utils/page-logic.ts index bc29276e7..f2648eb07 100644 --- a/tests/kit/utils/page-logic.ts +++ b/tests/kit/utils/page-logic.ts @@ -1,6 +1,24 @@ import type { Locator, Page } from '@playwright/test'; import { expect } from '@playwright/test'; +export function getAllPage(page: Page) { + const newPageButton = page.getByTestId('new-page-button-trigger'); + const newPageDropdown = newPageButton.locator('svg'); + const edgelessBlockCard = page.getByTestId('new-edgeless-button-in-all-page'); + + async function clickNewPageButton() { + const newPageButton = page.getByTestId('new-page-button-trigger'); + return await newPageButton.click(); + } + + async function clickNewEdgelessDropdown() { + await newPageDropdown.click(); + await edgelessBlockCard.click(); + } + + return { clickNewPageButton, clickNewEdgelessDropdown }; +} + export async function waitForEditorLoad(page: Page) { await page.waitForSelector('v-line', { timeout: 20000, diff --git a/tools/copilot-result/index.js b/tools/copilot-result/index.js new file mode 100644 index 000000000..77d8e26bc --- /dev/null +++ b/tools/copilot-result/index.js @@ -0,0 +1,19 @@ +import { WebClient } from '@slack/web-api'; + +import { render } from './markdown.js'; + +const { CHANNEL_ID, SLACK_BOT_TOKEN, COPILOT_RESULT, BRANCH_SHA, BRANCH_NAME } = + process.env; + +const { ok } = await new WebClient(SLACK_BOT_TOKEN).chat.postMessage({ + channel: CHANNEL_ID, + text: `AFFiNE Copilot Test ${COPILOT_RESULT}`, + blocks: render( + `# AFFiNE Copilot Test ${COPILOT_RESULT} + +- [${BRANCH_NAME?.replace('refs/heads/', '') || BRANCH_SHA}](https://github.com/toeverything/AFFiNE/commit/${BRANCH_SHA}) +` + ), +}); + +console.assert(ok, 'Failed to send a message to Slack'); diff --git a/tools/copilot-result/markdown.js b/tools/copilot-result/markdown.js new file mode 100644 index 000000000..0c1774e49 --- /dev/null +++ b/tools/copilot-result/markdown.js @@ -0,0 +1,25 @@ +import { jsxslack } from 'jsx-slack'; +import { marked, Renderer } from 'marked'; + +export const render = markdown => { + const rendered = marked(markdown, { + renderer: new (class CustomRenderer extends Renderer { + heading({ tokens }) { + return ` + +
${tokens[0].text}
+ +
`; + } + + list(token) { + return `
${super.list(token)}
`; + } + + hr() { + return ``; + } + })(), + }); + return jsxslack([`${rendered}`]); +}; diff --git a/tools/copilot-result/package.json b/tools/copilot-result/package.json new file mode 100644 index 000000000..4fe2f6927 --- /dev/null +++ b/tools/copilot-result/package.json @@ -0,0 +1,16 @@ +{ + "name": "@affine/copilot-result", + "version": "0.17.0", + "type": "module", + "main": "index.js", + "private": true, + "description": "Send copilot result to slack", + "dependencies": { + "@slack/web-api": "^7.3.4", + "jsx-slack": "^6.1.1", + "marked": "^14.0.0" + }, + "devDependencies": { + "@types/node": "^20.14.12" + } +} diff --git a/yarn.lock b/yarn.lock index 8dacd24e0..fe6004a23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -67,6 +67,15 @@ __metadata: languageName: unknown linkType: soft +"@affine-test/affine-cloud-copilot@workspace:tests/affine-cloud-copilot": + version: 0.0.0-use.local + resolution: "@affine-test/affine-cloud-copilot@workspace:tests/affine-cloud-copilot" + dependencies: + "@affine-test/kit": "workspace:*" + "@playwright/test": "npm:=1.47.2" + languageName: unknown + linkType: soft + "@affine-test/affine-cloud@workspace:tests/affine-cloud": version: 0.0.0-use.local resolution: "@affine-test/affine-cloud@workspace:tests/affine-cloud" @@ -377,6 +386,17 @@ __metadata: languageName: unknown linkType: soft +"@affine/copilot-result@workspace:tools/copilot-result": + version: 0.0.0-use.local + resolution: "@affine/copilot-result@workspace:tools/copilot-result" + dependencies: + "@slack/web-api": "npm:^7.3.4" + "@types/node": "npm:^20.14.12" + jsx-slack: "npm:^6.1.1" + marked: "npm:^14.0.0" + languageName: unknown + linkType: soft + "@affine/core@workspace:*, @affine/core@workspace:packages/frontend/core": version: 0.0.0-use.local resolution: "@affine/core@workspace:packages/frontend/core"