Merge pull request #680 from toeverything/feat/cloud-sync

feat: auth & sync
This commit is contained in:
DarkSky 2023-01-06 15:02:27 +08:00 committed by GitHub
commit 12804b0dea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 754 additions and 190 deletions

View File

@ -5,7 +5,7 @@
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "feat/filesystem_and_search",
"baseBranch": "feat/cloud-sync",
"updateInternalDependencies": "patch",
"ignore": []
}

View File

@ -4,3 +4,5 @@
**/node_modules/**
.github/**
**/__tests__/**
**/tests/**

View File

@ -1,9 +1,7 @@
---
name: I have a question
about: Feel free to ask us your questions!
title: "[Question]"
title: '[Question]'
labels: ''
assignees: ''
---

View File

@ -2,7 +2,7 @@ name: Pathfinder changelog
on:
push:
branches: [feat/filesystem_and_search, master]
branches: [feat/cloud-sync, master]
# Cancels all previous workflow runs for pull requests that have not completed.
# See https://docs.github.com/en/actions/using-jobs/using-concurrency

128
.github/workflows/temp_test.yml vendored Normal file
View File

@ -0,0 +1,128 @@
name: Pathfinder Check
on:
push:
branches: [feat/cloud-sync]
pull_request:
branches: [feat/cloud-sync]
# Cancels all previous workflow runs for pull requests that have not completed.
# See https://docs.github.com/en/actions/using-jobs/using-concurrency
concurrency:
# The concurrency group contains the workflow name and the branch name for
# pull requests or the commit hash for any other events.
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}
cancel-in-progress: true
jobs:
build:
name: Build on Pull Request
if: github.ref != 'refs/heads/master'
runs-on: self-hosted
environment: development
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2
with:
version: 'latest'
- name: Use Node.js
uses: actions/setup-node@v2
with:
node-version: 18.x
registry-url: https://npm.pkg.github.com
scope: '@toeverything'
cache: 'pnpm'
- run: node scripts/module-resolve/ci.js
- name: Restore cache
uses: actions/cache@v3
with:
path: |
.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_GITHUB_AUTH_TOKEN }}
- name: Build
run: pnpm build
env:
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
- name: Export
run: pnpm export
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
path: ./packages/app/.next
lint:
name: Lint and E2E Test
runs-on: ubuntu-latest
environment: development
needs: build
steps:
- uses: actions/checkout@v2
- uses: pnpm/action-setup@v2
with:
version: 'latest'
- name: Use Node.js
uses: actions/setup-node@v2
with:
node-version: 18.x
cache: 'pnpm'
- name: Restore cache
uses: actions/cache@v3
with:
path: |
.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-
- name: Install dependencies
run: pnpm install
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_GITHUB_AUTH_TOKEN }}
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: artifact
path: packages/app/.next/
- name: Lint & E2E Test
run: |
pnpm lint --max-warnings=0
PLAYWRIGHT_BROWSERS_PATH=0 npx playwright install chromium
PLAYWRIGHT_BROWSERS_PATH=0 pnpm test
PLAYWRIGHT_BROWSERS_PATH=0 pnpm test:dc
env:
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}

View File

@ -16,7 +16,8 @@
"test:e2e:codegen": "npx playwright codegen http://localhost:8080",
"test:unit": "jest",
"postinstall": "husky install",
"notify": "node --experimental-modules scripts/notify.mjs"
"notify": "node --experimental-modules scripts/notify.mjs",
"check:ci": "pnpm lint & pnpm test"
},
"lint-staged": {
"*": "prettier --write --ignore-unknown",

View File

@ -11,7 +11,7 @@ import { getWarningMessage, shouldShowWarning } from './utils';
import EditorOptionMenu from './header-right-items/editor-option-menu';
import TrashButtonGroup from './header-right-items/trash-button-group';
import ThemeModeSwitch from './header-right-items/theme-mode-switch';
// import SyncUser from './header-right-items/sync-user';
import SyncUser from './header-right-items/sync-user';
const BrowserWarning = ({
show,
@ -40,7 +40,7 @@ const HeaderRightItems: Record<HeaderRightItemNames, ReactNode> = {
editorOptionMenu: <EditorOptionMenu key="editorOptionMenu" />,
trashButtonGroup: <TrashButtonGroup key="trashButtonGroup" />,
themeModeSwitch: <ThemeModeSwitch key="themeModeSwitch" />,
syncUser: null,
syncUser: <SyncUser key="syncUser" />,
};
export const Header = ({

View File

@ -4,13 +4,14 @@ import {
StyledArrowButton,
StyledLink,
StyledListItem,
// StyledListItemForWorkspace,
StyledListItemForWorkspace,
StyledNewPageButton,
StyledSliderBar,
StyledSliderBarWrapper,
StyledSubListItem,
} from './style';
import { Arrow } from './icons';
import { WorkspaceSelector } from './WorkspaceSelector';
import Collapse from '@mui/material/Collapse';
import {
ArrowDownIcon,
@ -26,7 +27,6 @@ import { Tooltip } from '@/ui/tooltip';
import { useModal } from '@/providers/global-modal-provider';
import { useAppState } from '@/providers/app-state-provider/context';
import { IconButton } from '@/ui/button';
// import { WorkspaceSelector } from './WorkspaceSelector';
import useLocalStorage from '@/hooks/use-local-storage';
import usePageMetaList from '@/hooks/use-page-meta-list';
import { usePageHelper } from '@/hooks/use-page-helper';
@ -109,9 +109,9 @@ export const WorkSpaceSliderBar = () => {
</Tooltip>
<StyledSliderBarWrapper data-testid="sliderBar">
{/* <StyledListItemForWorkspace>
<StyledListItemForWorkspace>
<WorkspaceSelector />
</StyledListItemForWorkspace> */}
</StyledListItemForWorkspace>
<StyledListItem
data-testid="sliderBar-quickSearchButton"
style={{ cursor: 'pointer' }}

View File

@ -0,0 +1,90 @@
import { styled } from '@/styles';
import { ReactElement, ReactNode } from 'react';
import WorkspaceLayout from '@/components/workspace-layout';
import { Button } from '@/ui/button';
export const FeatureCardDiv = styled('section')({
width: '800px',
border: '1px #eee solid',
margin: '20px auto',
minHeight: '100px',
padding: '15px',
});
const FeatureCard = (props: {
name: string;
children: ReactNode | ReactNode[];
}) => {
return (
<FeatureCardDiv>
<h1>Feature - {props.name}</h1>
{props.children}
</FeatureCardDiv>
);
};
export const Playground = () => {
return (
<>
<FeatureCard name="Account">
<Button>Sign In</Button>
<Button>Sign Out</Button>
</FeatureCard>
<FeatureCard name="Workspace List">
<ul>
<li>AFFiNE Demo</li>
<li>AFFiNE XXX</li>
</ul>
<Button>New Workspace</Button>
</FeatureCard>
<FeatureCard name="Active Workspace">
<div>Workspace Name /[Workspace Members Count]/[Workspace Avatar]</div>
<div>Cloud Sync [Yes/No]</div>
<div>Auth [Public/Private]</div>
<div>
<Button>Update Workspace Name</Button>
<Button>Upload Workspace Avatar</Button>
<Button>Update Workspace Avatar</Button>
</div>
<div>
<Button>Leave Workspace</Button>
<Button>Delete Workspace </Button>
</div>
<div>
Cloud Sync <Button>Enalbe</Button>
<Button>Disable</Button>
</div>
</FeatureCard>
<FeatureCard name="Workspace Members">
<Button>Add Member</Button>
<ul>
<li>
terrychinaz@gmail <button>Delete Members</button>
</li>
</ul>
</FeatureCard>
<FeatureCard name="Cloud Search">
<input type="text" value="AFFiNE Keywords" />
<Button>Search</Button>
<ul></ul>
</FeatureCard>
<FeatureCard name="Import/Exeport Worpsace">
<div>Workspace Name</div>
<Button> Export Workspace</Button>
<Button> Import Workspace</Button>
</FeatureCard>
</>
);
};
Playground.getLayout = function getLayout(page: ReactElement) {
return <WorkspaceLayout>{page}</WorkspaceLayout>;
};
export default Playground;

View File

@ -60,7 +60,9 @@ class Token {
}
async initToken(token: string) {
this._setToken(await login({ token, type: 'Google' }));
const tokens = await login({ token, type: 'Google' });
this._setToken(tokens);
return this._user;
}
async refreshToken(token?: string) {
@ -153,10 +155,27 @@ export const getAuthorizer = () => {
const googleAuthProvider = new GoogleAuthProvider();
const getToken = async () => {
const currentUser = firebaseAuth.currentUser;
if (currentUser) {
await currentUser.getIdTokenResult(true);
if (!currentUser.isAnonymous) {
return currentUser.getIdToken();
}
}
return;
};
const signInWithGoogle = async () => {
const user = await signInWithPopup(firebaseAuth, googleAuthProvider);
const idToken = await user.user.getIdToken();
await token.initToken(idToken);
const idToken = await getToken();
if (idToken) {
await token.initToken(idToken);
} else {
const user = await signInWithPopup(firebaseAuth, googleAuthProvider);
const idToken = await user.user.getIdToken();
await token.initToken(idToken);
}
return firebaseAuth.currentUser;
};
const onAuthStateChanged = (callback: (user: User | null) => void) => {

View File

@ -1,6 +1,6 @@
import assert from 'assert';
import { BlockSchema } from '@blocksuite/blocks/models';
import { Workspace } from '@blocksuite/store';
import { Workspace, Signal } from '@blocksuite/store';
import { getLogger } from './index.js';
import { getApis, Apis } from './apis/index.js';
@ -16,6 +16,17 @@ type LoadConfig = {
config?: Record<string, any>;
};
export type DataCenterSignals = DataCenter['signals'];
type WorkspaceItem = {
// provider id
provider: string;
// data exists locally
locally: boolean;
};
type WorkspaceLoadEvent = WorkspaceItem & {
workspace: string;
};
export class DataCenter {
private readonly _apis: Apis;
private readonly _providers = new Map<string, typeof BaseProvider>();
@ -23,6 +34,11 @@ export class DataCenter {
private readonly _config;
private readonly _logger;
readonly signals = {
listAdd: new Signal<WorkspaceLoadEvent>(),
listRemove: new Signal<string>(),
};
static async init(debug: boolean): Promise<DataCenter> {
const dc = new DataCenter(debug);
dc.addProvider(AffineProvider);
@ -36,6 +52,16 @@ export class DataCenter {
this._config = getKVConfigure('sys');
this._logger = getLogger('dc');
this._logger.enabled = debug;
this.signals.listAdd.on(e => {
this._config.set(`list:${e.workspace}`, {
provider: e.provider,
locally: e.locally,
});
});
this.signals.listRemove.on(workspace => {
this._config.delete(`list:${workspace}`);
});
}
get apis(): Readonly<Apis> {
@ -86,9 +112,9 @@ export class DataCenter {
await provider.init({
apis: this._apis,
config,
globalConfig: getKVConfigure(`provider:${providerId}`),
debug: this._logger.enabled,
logger: this._logger.extend(`${Provider.id}:${id}`),
signals: this.signals,
workspace,
});
await provider.initData();
@ -97,6 +123,21 @@ export class DataCenter {
return provider;
}
async auth(providerId: string, globalConfig?: Record<string, any>) {
const Provider = this._providers.get(providerId);
if (Provider) {
// initial configurator
const config = getKVConfigure(`provider:${providerId}`);
// set workspace configs
const values = Object.entries(globalConfig || {});
if (values.length) await config.setMany(values);
const logger = this._logger.extend(`auth:${providerId}`);
logger.enabled = this._logger.enabled;
await Provider.auth(config, logger, this.signals);
}
}
/**
* load workspace data to memory
* @param workspaceId workspace id
@ -150,24 +191,18 @@ export class DataCenter {
}
/**
* get workspace list
* get workspace listreturn a map of workspace id and data state
* data state is also map, the key is the provider id, and the data exists locally when the value is true, otherwise it does not exist
*/
async list(): Promise<Record<string, Record<string, boolean>>> {
const lists = await Promise.all(
Array.from(this._providers.entries()).map(([providerId, provider]) =>
provider
.list(getKVConfigure(`provider:${providerId}`))
.then(list => [providerId, list || []] as const)
)
);
return lists.reduce((ret, [providerId, list]) => {
for (const [item, isLocal] of list) {
const workspace = ret[item] || {};
workspace[providerId] = isLocal;
ret[item] = workspace;
const entries: [string, WorkspaceItem][] = await this._config.entries();
return entries.reduce((acc, [k, i]) => {
if (k.startsWith('list:')) {
const key = k.slice(5);
acc[key] = acc[key] || {};
acc[key][i.provider] = i.locally;
}
return ret;
return acc;
}, {} as Record<string, Record<string, boolean>>);
}

View File

@ -7,6 +7,17 @@ const _initializeDataCenter = () => {
return (debug = true) => {
if (!_dataCenterInstance) {
_dataCenterInstance = DataCenter.init(debug);
_dataCenterInstance.then(dc => {
try {
if (window) {
(window as any).dc = dc;
}
} catch (_) {
// ignore
}
return dc;
});
}
return _dataCenterInstance;

View File

@ -1,11 +1,17 @@
import assert from 'assert';
import { applyUpdate } from 'yjs';
import { applyUpdate, Doc } from 'yjs';
import type { InitialParams } from '../index.js';
import { token, Callback } from '../../apis/index.js';
import type {
ConfigStore,
DataCenterSignals,
InitialParams,
Logger,
} from '../index.js';
import { token, Callback, getApis } from '../../apis/index.js';
import { LocalProvider } from '../local/index.js';
import { WebsocketProvider } from './sync.js';
import { IndexedDBProvider } from '../local/indexeddb.js';
export class AffineProvider extends LocalProvider {
static id = 'affine';
@ -55,7 +61,14 @@ export class AffineProvider extends LocalProvider {
}
async initData() {
await super.initData();
const databases = await indexedDB.databases();
await super.initData(
// set locally to true if exists a same name db
databases
.map(db => db.name)
.filter(v => v)
.includes(this._workspace.room)
);
const workspace = this._workspace;
const doc = workspace.doc;
@ -64,23 +77,29 @@ export class AffineProvider extends LocalProvider {
if (workspace.room && token.isLogin) {
try {
const updates = await this._apis.downloadWorkspace(workspace.room);
if (updates) {
await new Promise(resolve => {
doc.once('update', resolve);
applyUpdate(doc, new Uint8Array(updates));
});
// Wait for ws synchronization to complete, otherwise the data will be modified in reverse, which can be optimized later
this._ws = new WebsocketProvider('/', workspace.room, doc);
await new Promise<void>((resolve, reject) => {
// TODO: synced will also be triggered on reconnection after losing sync
// There needs to be an event mechanism to emit the synchronization state to the upper layer
assert(this._ws);
this._ws.once('synced', () => resolve());
this._ws.once('lost-connection', () => resolve());
this._ws.once('connection-error', () => reject());
});
}
// init data from cloud
await AffineProvider._initCloudDoc(
workspace.room,
doc,
this._logger,
this._signals
);
// Wait for ws synchronization to complete, otherwise the data will be modified in reverse, which can be optimized later
this._ws = new WebsocketProvider('/', workspace.room, doc);
await new Promise<void>((resolve, reject) => {
// TODO: synced will also be triggered on reconnection after losing sync
// There needs to be an event mechanism to emit the synchronization state to the upper layer
assert(this._ws);
this._ws.once('synced', () => resolve());
this._ws.once('lost-connection', () => resolve());
this._ws.once('connection-error', () => reject());
});
this._signals.listAdd.emit({
workspace: workspace.room,
provider: this.id,
locally: true,
});
} catch (e) {
this._logger('Failed to init cloud workspace', e);
}
@ -91,4 +110,66 @@ export class AffineProvider extends LocalProvider {
// just a workaround for yjs
doc.getMap('space:meta');
}
private static async _initCloudDoc(
workspace: string,
doc: Doc,
logger: Logger,
signals: DataCenterSignals
) {
const apis = getApis();
logger(`Loading ${workspace}...`);
const updates = await apis.downloadWorkspace(workspace);
if (updates) {
await new Promise(resolve => {
doc.once('update', resolve);
applyUpdate(doc, new Uint8Array(updates));
});
logger(`Loaded: ${workspace}`);
// only add to list as online workspace
signals.listAdd.emit({
workspace,
provider: this.id,
// at this time we always download full workspace
// but after we support sub doc, we can only download metadata
locally: false,
});
}
}
static async auth(
config: Readonly<ConfigStore<string>>,
logger: Logger,
signals: DataCenterSignals
) {
const refreshToken = await config.get('token');
if (refreshToken) {
await token.refreshToken(refreshToken);
if (token.isLogin && !token.isExpired) {
logger('check login success');
// login success
return;
}
}
logger('start login');
// login with google
const apis = getApis();
assert(apis.signInWithGoogle);
const user = await apis.signInWithGoogle();
assert(user);
logger(`login success: ${user.displayName}`);
// TODO: refresh local workspace data
const workspaces = await apis.getWorkspaces();
await Promise.all(
workspaces.map(async ({ id }) => {
const doc = new Doc();
const idb = new IndexedDBProvider(id, doc);
await idb.whenSynced;
await this._initCloudDoc(id, doc, logger, signals);
})
);
}
}

View File

@ -1,14 +1,20 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { Workspace } from '@blocksuite/store';
import type { Apis, Logger, InitialParams, ConfigStore } from './index';
import type {
Apis,
DataCenterSignals,
Logger,
InitialParams,
ConfigStore,
} from './index';
export class BaseProvider {
static id = 'base';
protected _apis!: Readonly<Apis>;
protected _config!: Readonly<ConfigStore>;
protected _globalConfig!: Readonly<ConfigStore>;
protected _logger!: Logger;
protected _signals!: DataCenterSignals;
protected _workspace!: Workspace;
constructor() {
@ -22,8 +28,8 @@ export class BaseProvider {
async init(params: InitialParams) {
this._apis = params.apis;
this._config = params.config;
this._globalConfig = params.globalConfig;
this._logger = params.logger;
this._signals = params.signals;
this._workspace = params.workspace;
this._logger.enabled = params.debug;
}
@ -55,6 +61,14 @@ export class BaseProvider {
return this._workspace;
}
static async auth(
_config: Readonly<ConfigStore>,
logger: Logger,
_signals: DataCenterSignals
) {
logger("This provider doesn't require authentication");
}
// get workspace listreturn a map of workspace id and boolean
// if value is true, it exists locally, otherwise it does not exist locally
static async list(

View File

@ -1,6 +1,7 @@
import type { Workspace } from '@blocksuite/store';
import type { Apis } from '../apis';
import type { DataCenterSignals } from '../datacenter';
import type { getLogger } from '../index';
import type { ConfigStore } from '../store';
@ -9,13 +10,13 @@ export type Logger = ReturnType<typeof getLogger>;
export type InitialParams = {
apis: Apis;
config: Readonly<ConfigStore>;
globalConfig: Readonly<ConfigStore>;
debug: boolean;
logger: Logger;
signals: DataCenterSignals;
workspace: Workspace;
};
export type { Apis, ConfigStore, Workspace };
export type { Apis, ConfigStore, DataCenterSignals, Workspace };
export type { BaseProvider } from './base.js';
export { AffineProvider } from './affine/index.js';
export { LocalProvider } from './local/index.js';

View File

@ -21,7 +21,7 @@ export class LocalProvider extends BaseProvider {
this._blobs = blobs;
}
async initData() {
async initData(locally = true) {
assert(this._workspace.room);
this._logger('Loading local data');
this._idb = new IndexedDBProvider(
@ -32,14 +32,19 @@ export class LocalProvider extends BaseProvider {
await this._idb.whenSynced;
this._logger('Local data loaded');
await this._globalConfig.set(this._workspace.room, true);
this._signals.listAdd.emit({
workspace: this._workspace.room,
provider: this.id,
locally,
});
}
async clear() {
assert(this._workspace.room);
await super.clear();
await this._blobs.clear();
await this._idb?.clearData();
await this._globalConfig.delete(this._workspace.room!);
this._signals.listRemove.emit(this._workspace.room);
}
async destroy(): Promise<void> {
@ -59,6 +64,10 @@ export class LocalProvider extends BaseProvider {
config: Readonly<ConfigStore<boolean>>
): Promise<Map<string, boolean> | undefined> {
const entries = await config.entries();
return new Map(entries);
return new Map(
entries
.filter(([key]) => key.startsWith('list:'))
.map(([key, value]) => [key.slice(5), value])
);
}
}

View File

@ -0,0 +1,15 @@
import { test, expect } from '@playwright/test';
import { getDataCenter } from '../utils.js';
import 'fake-indexeddb/auto';
test.describe('Auth', () => {
test('sign in', async () => {});
test('sign out', async () => {});
test('isLogin', async () => {});
test('getUserInfo', async () => {});
});

View File

@ -0,0 +1,15 @@
import { test, expect } from '@playwright/test';
import { getDataCenter } from '../utils.js';
import 'fake-indexeddb/auto';
test.describe('Collaborate', () => {
test('collaborate editor content', async () => {});
test('collaborate workspace name', async () => {});
test('collaborate workspace avatar', async () => {});
test('collaborate workspace list', async () => {});
});

View File

@ -0,0 +1,17 @@
import { test, expect } from '@playwright/test';
import { getDataCenter } from '../utils.js';
import 'fake-indexeddb/auto';
test.describe('Permission', () => {
test('get the public of workspace', async () => {});
test('make workspace public', async () => {});
test('make workspace private', async () => {});
test('un-login user open the public workspace ', async () => {});
test('un-login user open the private workspace ', async () => {});
});

View File

@ -0,0 +1,15 @@
import { test, expect } from '@playwright/test';
import { getDataCenter } from '../utils.js';
import 'fake-indexeddb/auto';
test.describe('Share', () => {
test('add(invite) member by email', async () => {});
test('accept invite member link', async () => {});
test('members list', async () => {});
test('delete member', async () => {});
});

View File

@ -0,0 +1,23 @@
import { test, expect } from '@playwright/test';
import { getDataCenter } from '../utils.js';
import 'fake-indexeddb/auto';
test.describe('Sync', () => {
test('get cloud the sync flag of workspace', async () => {});
test('enable [cloud sync feature]', async () => {});
test('close [cloud sync feature]', async () => {});
test('editor cloud storage', async () => {});
test('cloud sync is in-progress', async () => {});
test('cloud sync is completed', async () => {});
test('cloud sync is error', async () => {});
test('cloud storage is right', async () => {});
});

View File

@ -1,99 +0,0 @@
import { test, expect } from '@playwright/test';
import { getDataCenter } from './utils.js';
import 'fake-indexeddb/auto';
test('init data center', async () => {
const dataCenter = await getDataCenter();
expect(dataCenter).toBeTruthy();
await dataCenter.clear();
const workspace = await dataCenter.load('test1');
expect(workspace).toBeTruthy();
});
test('init data center singleton', async () => {
// data center is singleton
const [dc1, dc2] = await Promise.all([getDataCenter(), getDataCenter()]);
expect(dc1).toEqual(dc2);
// load same workspace will get same instance
const [ws1, ws2] = await Promise.all([dc1.load('test1'), dc2.load('test1')]);
expect(ws1).toEqual(ws2);
});
test('should init error with unknown provider', async () => {
const dc = await getDataCenter();
await dc.clear();
// load workspace with unknown provider will throw error
test.fail();
await dc.load('test2', { providerId: 'not exist provider' });
});
test.skip('init affine provider', async () => {
const dataCenter = await getDataCenter();
await dataCenter.clear();
// load workspace with affine provider
// TODO: set constant token for testing
const workspace = await dataCenter.load('6', {
providerId: 'affine',
config: { token: 'YOUR_TOKEN' },
});
expect(workspace).toBeTruthy();
});
test('list workspaces', async () => {
const dataCenter = await getDataCenter();
await dataCenter.clear();
await Promise.all([
dataCenter.load('test3'),
dataCenter.load('test4'),
dataCenter.load('test5'),
dataCenter.load('test6'),
]);
expect(await dataCenter.list()).toStrictEqual({
test3: { local: true },
test4: { local: true },
test5: { local: true },
test6: { local: true },
});
await dataCenter.reload('test3', { providerId: 'affine' });
expect(await dataCenter.list()).toStrictEqual({
test3: { affine: true, local: true },
test4: { local: true },
test5: { local: true },
test6: { local: true },
});
});
test('destroy workspaces', async () => {
const dataCenter = await getDataCenter();
await dataCenter.clear();
// return new workspace if origin workspace is destroyed
const ws1 = await dataCenter.load('test7');
await dataCenter.destroy('test7');
const ws2 = await dataCenter.load('test7');
expect(ws1 !== ws2).toBeTruthy();
// return new workspace if workspace is reload
const ws3 = await dataCenter.load('test8');
const ws4 = await dataCenter.reload('test8', { providerId: 'affine' });
expect(ws3 !== ws4).toBeTruthy();
});
test('remove workspaces', async () => {
const dataCenter = await getDataCenter();
await dataCenter.clear();
// remove workspace will remove workspace data
await Promise.all([dataCenter.load('test9'), dataCenter.load('test10')]);
await dataCenter.delete('test9');
expect(await dataCenter.list()).toStrictEqual({ test10: { local: true } });
});

View File

@ -0,0 +1,13 @@
import { test, expect } from '@playwright/test';
import { getDataCenter } from '../utils.js';
import 'fake-indexeddb/auto';
test.describe('Attachment', () => {
test('upload blob', async () => {});
test('get blob', async () => {});
test('remove blob', async () => {});
});

View File

@ -0,0 +1,11 @@
import { test, expect } from '@playwright/test';
import { getDataCenter } from '../utils.js';
import 'fake-indexeddb/auto';
test.describe('Import/Export Workspace', () => {
test('import workspace', async () => {});
test('export workspace', async () => {});
});

View File

@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test';
import { getDataCenter } from '../utils.js';
import 'fake-indexeddb/auto';
test.describe('Init Data Center', () => {
test('init', async () => {
const dataCenter = await getDataCenter();
expect(dataCenter).toBeTruthy();
await dataCenter.clear();
const workspace = await dataCenter.load('test1');
expect(workspace).toBeTruthy();
});
test('init singleton', async () => {
// data center is singleton
const [dc1, dc2] = await Promise.all([getDataCenter(), getDataCenter()]);
expect(dc1).toEqual(dc2);
// load same workspace will get same instance
const [ws1, ws2] = await Promise.all([
dc1.load('test1'),
dc2.load('test1'),
]);
expect(ws1).toEqual(ws2);
});
test('should init error with unknown provider', async () => {
const dc = await getDataCenter();
await dc.clear();
// load workspace with unknown provider will throw error
test.fail();
await dc.load('test2', { providerId: 'not exist provider' });
});
test.skip('init affine provider', async () => {
const dataCenter = await getDataCenter();
await dataCenter.clear();
// load workspace with affine provider
// TODO: set constant token for testing
const workspace = await dataCenter.load('6', {
providerId: 'affine',
config: { token: 'YOUR_TOKEN' },
});
expect(workspace).toBeTruthy();
});
});

View File

@ -0,0 +1,26 @@
import assert from 'assert';
import { test, expect } from '@playwright/test';
import { getDataCenter, waitOnce } from '../utils.js';
import 'fake-indexeddb/auto';
test.describe('Search', () => {
test('search result', async () => {
const dc = await getDataCenter();
const workspace = await dc.load('test');
assert(workspace);
workspace.createPage('test');
await waitOnce(workspace.signals.pageAdded);
const page = workspace.getPage('test');
assert(page);
const text = new page.Text(page, 'hello world');
const blockId = page.addBlock({ flavour: 'affine:paragraph', text });
expect(workspace.search('hello')).toStrictEqual(
new Map([[blockId, 'test']])
);
});
});

View File

@ -0,0 +1,70 @@
import { test, expect } from '@playwright/test';
import { getDataCenter } from '../utils.js';
import 'fake-indexeddb/auto';
test.describe('Workspace', () => {
test('create', async () => {});
test('load', async () => {});
test('get workspace name', async () => {});
test('set workspace name', async () => {});
test('get workspace avatar', async () => {});
test('set workspace avatar', async () => {});
test('list', async () => {
const dataCenter = await getDataCenter();
await dataCenter.clear();
await Promise.all([
dataCenter.load('test3'),
dataCenter.load('test4'),
dataCenter.load('test5'),
dataCenter.load('test6'),
]);
expect(await dataCenter.list()).toStrictEqual({
test3: { local: true },
test4: { local: true },
test5: { local: true },
test6: { local: true },
});
await dataCenter.reload('test3', { providerId: 'affine' });
expect(await dataCenter.list()).toStrictEqual({
test3: { affine: true },
test4: { local: true },
test5: { local: true },
test6: { local: true },
});
});
test('destroy', async () => {
const dataCenter = await getDataCenter();
await dataCenter.clear();
// return new workspace if origin workspace is destroyed
const ws1 = await dataCenter.load('test7');
await dataCenter.destroy('test7');
const ws2 = await dataCenter.load('test7');
expect(ws1 !== ws2).toBeTruthy();
// return new workspace if workspace is reload
const ws3 = await dataCenter.load('test8');
const ws4 = await dataCenter.reload('test8', { providerId: 'affine' });
expect(ws3 !== ws4).toBeTruthy();
});
test('remove', async () => {
const dataCenter = await getDataCenter();
await dataCenter.clear();
// remove workspace will remove workspace data
await Promise.all([dataCenter.load('test9'), dataCenter.load('test10')]);
await dataCenter.delete('test9');
expect(await dataCenter.list()).toStrictEqual({ test10: { local: true } });
});
});

View File

@ -1,5 +1,9 @@
export const getDataCenter = () => {
return import('../src/index.js').then(async dataCenter =>
dataCenter.getDataCenter(false)
);
import { Signal } from '@blocksuite/store';
export const getDataCenter = async () => {
const dataCenter = await import('../src/index.js');
return await dataCenter.getDataCenter(false);
};
export const waitOnce = <T>(signal: Signal<T>) =>
new Promise<T>(resolve => signal.once(val => resolve(val)));

View File

@ -4,11 +4,18 @@ import { loadPage } from './libs/load-page';
loadPage();
test.describe('Open contact us', () => {
test.skip('Click about us', async ({ page }) => {
test('Click about us', async ({ page }) => {
const currentWorkspace = page.getByTestId('current-workspace');
await currentWorkspace.click();
// await page.waitForTimeout(1000);
await page.getByText('About AFFiNE').click();
await page
.getByRole('tooltip', {
name: 'AFFiNE Log in to sync with affine About AFFiNE',
})
.locator('div')
.filter({ hasText: 'About AFFiNE' })
.nth(2)
.click();
const contactUsModal = page.locator(
'[data-testid=contact-us-modal-content]'
);

View File

@ -1,7 +1,11 @@
import { test } from '@playwright/test';
import type { Page } from '@playwright/test';
interface IType {
page: Page;
}
export function loadPage() {
test.beforeEach(async ({ page }) => {
test.beforeEach(async ({ page }: IType) => {
await page.goto('http://localhost:8080');
// waiting for page loading end
await page.waitForSelector('#__next');

View File

@ -1,11 +1,13 @@
export async function newPage(page) {
import type { Page } from '@playwright/test';
export async function newPage(page: Page) {
return page.getByTestId('sliderBar').getByText('New Page').click();
}
export async function clickPageMoreActions(page) {
export async function clickPageMoreActions(page: Page) {
return page
.getByTestId('editor-header-items')
.getByRole('button')
.nth(1)
.nth(2)
.click();
}

View File

@ -3,7 +3,7 @@ import { loadPage } from './libs/load-page';
loadPage();
test.describe.skip('Local first default workspace', () => {
test.describe('Local first default workspace', () => {
test('Default workspace name', async ({ page }) => {
const workspaceName = page.getByTestId('workspace-name');
expect(await workspaceName.textContent()).toBe('AFFiNE');

View File

@ -3,7 +3,7 @@ import { loadPage } from './libs/load-page';
loadPage();
test.describe.skip('Login Flow', () => {
test.describe('Login Flow', () => {
test('Open login modal by click current workspace', async ({ page }) => {
await page.getByTestId('current-workspace').click();
await page.waitForTimeout(800);
@ -24,21 +24,22 @@ test.describe.skip('Login Flow', () => {
.click();
});
test('Open google firebase page', async ({ page }) => {
await page.getByTestId('current-workspace').click();
await page.waitForTimeout(800);
// why don't we use waitForSelector, It seems that waitForSelector not stable?
await page.getByTestId('open-login-modal').click();
await page.waitForTimeout(800);
const [firebasePage] = await Promise.all([
page.waitForEvent('popup'),
page
.getByRole('button', {
name: 'Google Continue with Google Set up an AFFiNE account to sync data',
})
.click(),
]);
// not stable
// test.skip('Open google firebase page', async ({ page }) => {
// await page.getByTestId('current-workspace').click();
// await page.waitForTimeout(800);
// // why don't we use waitForSelector, It seems that waitForSelector not stable?
// await page.getByTestId('open-login-modal').click();
// await page.waitForTimeout(800);
// const [firebasePage] = await Promise.all([
// page.waitForEvent('popup'),
// page
// .getByRole('button', {
// name: 'Google Continue with Google Set up an AFFiNE account to sync data',
// })
// .click(),
// ]);
expect(firebasePage.url()).toContain('.firebaseapp.com/__/auth/handler');
});
// expect(firebasePage.url()).toContain('.firebaseapp.com/__/auth/handler');
// });
});