{
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 `
+
+
+
+ `;
+ }
+
+ list(token) {
+ return ``;
+ }
+
+ 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"