snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`snapshot to markdown > imports obsidian vault fixtures 1`] = `
{
"entry": {
"children": [
{
"children": [
{
"children": [
{
"delta": [
{
"insert": "Panel
Body line",
},
],
"flavour": "affine:paragraph",
"type": "text",
},
],
"emoji": "💡",
"flavour": "affine:callout",
},
{
"flavour": "affine:attachment",
"name": "archive.zip",
"style": "horizontalThin",
},
{
"delta": [
{
"footnote": {
"label": "1",
"reference": {
"title": "reference body",
"type": "url",
},
},
"insert": " ",
},
],
"flavour": "affine:paragraph",
"type": "text",
},
{
"flavour": "affine:divider",
},
{
"delta": [
{
"insert": "after note",
},
],
"flavour": "affine:paragraph",
"type": "text",
},
{
"delta": [
{
"insert": " ",
"reference": {
"page": "linked",
"type": "LinkedPage",
},
},
],
"flavour": "affine:paragraph",
"type": "text",
},
{
"delta": [
{
"insert": "Sources",
},
],
"flavour": "affine:paragraph",
"type": "h6",
},
{
"flavour": "affine:bookmark",
},
],
"flavour": "affine:note",
},
],
"flavour": "affine:page",
},
"titles": [
"entry",
"linked",
],
}
`;

View File

@@ -0,0 +1,14 @@
> [!custom] Panel
> Body line
![[archive.zip]]
[^1]
---
after note
[[linked]]
[^1]: reference body

View File

@@ -0,0 +1 @@
plain linked page

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,170 @@
import { DefaultTheme } from '@blocksuite/affine-model';
import { NotionTextAdapter } from '@blocksuite/affine-shared/adapters';
import type { SliceSnapshot } from '@blocksuite/store';
import { describe, expect, test } from 'vitest';
import { createJob } from '../utils/create-job.js';
import { getProvider } from '../utils/get-provider.js';
import { nanoidReplacement } from '../utils/nanoid-replacement.js';
const provider = getProvider();
describe('notion-text to snapshot', () => {
test('basic', () => {
const notionText =
'{"blockType":"text","editing":[["aaa ",[["_"],["b"],["i"]]],["nbbbb ",[["_"],["i"]]],["hjhj ",[["_"]]],["a",[["_"],["c"]]],[" ",[["_"]]],["ccc d",[["_"],["s"]]],["dd",[["_"],["s"]]]]}';
const sliceSnapshot: SliceSnapshot = {
type: 'slice',
content: [
{
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: 'both',
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'aaa ',
attributes: {
underline: true,
bold: true,
italic: true,
},
},
{
insert: 'nbbbb ',
attributes: {
underline: true,
italic: true,
},
},
{
insert: 'hjhj ',
attributes: {
underline: true,
},
},
{
insert: 'a',
attributes: {
underline: true,
code: true,
},
},
{
insert: ' ',
attributes: {
underline: true,
},
},
{
insert: 'ccc d',
attributes: {
underline: true,
strike: true,
},
},
{
insert: 'dd',
attributes: {
underline: true,
strike: true,
},
},
],
},
},
children: [],
},
],
},
],
workspaceId: '',
pageId: '',
};
const ntAdapter = new NotionTextAdapter(createJob(), provider);
const target = ntAdapter.toSliceSnapshot({
file: notionText,
workspaceId: '',
pageId: '',
});
expect(nanoidReplacement(target!)).toEqual(sliceSnapshot);
});
test('notion text with empty styles array', () => {
const notionText =
'{"blockType":"text","editing":[["a "],["bold text",[["b"]]],[" hello world"]],"selection":{"startIndex":0,"endIndex":23},"action":"copy"}';
const sliceSnapshot: SliceSnapshot = {
type: 'slice',
content: [
{
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: 'both',
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'a ',
},
{
insert: 'bold text',
attributes: {
bold: true,
},
},
{
insert: ' hello world',
},
],
},
},
children: [],
},
],
},
],
workspaceId: '',
pageId: '',
};
const ntAdapter = new NotionTextAdapter(createJob(), provider);
const target = ntAdapter.toSliceSnapshot({
file: notionText,
workspaceId: '',
pageId: '',
});
expect(nanoidReplacement(target!)).toEqual(sliceSnapshot);
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,236 @@
import {
addProperty,
copyCellsByProperty,
databaseBlockProperties,
deleteColumn,
getCell,
getProperty,
updateCell,
} from '@blocksuite/affine-block-database';
import {
type CellDataType,
type ColumnDataType,
type DatabaseBlockModel,
DatabaseBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
} from '@blocksuite/affine-model';
import { propertyModelPresets } from '@blocksuite/data-view/property-pure-presets';
import type { BlockModel, Store } from '@blocksuite/store';
import { Text } from '@blocksuite/store';
import {
createAutoIncrementIdGenerator,
TestWorkspace,
} from '@blocksuite/store/test';
import { beforeEach, describe, expect, test } from 'vitest';
const extensions = [
RootBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
DatabaseBlockSchemaExtension,
];
function createTestOptions() {
const idGenerator = createAutoIncrementIdGenerator();
return { id: 'test-collection', idGenerator };
}
function createTestDoc(docId = 'doc0') {
const options = createTestOptions();
const collection = new TestWorkspace(options);
collection.meta.initialize();
const doc = collection.createDoc(docId);
doc.load();
return doc.getStore({ extensions });
}
describe('DatabaseManager', () => {
let doc: Store;
let db: DatabaseBlockModel;
let rootId: BlockModel['id'];
let noteBlockId: BlockModel['id'];
let databaseBlockId: BlockModel['id'];
let p1: BlockModel['id'];
let p2: BlockModel['id'];
let col1: ColumnDataType['id'];
let col2: ColumnDataType['id'];
let col3: ColumnDataType['id'];
const selection = [
{ id: '1', value: 'Done', color: 'var(--affine-tag-white)' },
{ id: '2', value: 'TODO', color: 'var(--affine-tag-pink)' },
{ id: '3', value: 'WIP', color: 'var(--affine-tag-blue)' },
];
beforeEach(() => {
doc = createTestDoc();
rootId = doc.addBlock('affine:page', {
title: new Text('database test'),
});
noteBlockId = doc.addBlock('affine:note', {}, rootId);
databaseBlockId = doc.addBlock(
'affine:database',
{
columns: [],
titleColumn: 'Title',
},
noteBlockId
);
const databaseModel = doc.getModelById(
databaseBlockId
) as DatabaseBlockModel;
db = databaseModel;
col1 = addProperty(
db,
'end',
databaseBlockProperties.numberColumnConfig.create('Number')
);
col2 = addProperty(
db,
'end',
propertyModelPresets.selectPropertyModelConfig.create('Single Select', {
options: selection,
})
);
col3 = addProperty(
db,
'end',
databaseBlockProperties.richTextColumnConfig.create('Rich Text')
);
doc.updateBlock(databaseModel, {
columns: [col1, col2, col3],
});
p1 = doc.addBlock(
'affine:paragraph',
{
text: new Text('text1'),
},
databaseBlockId
);
p2 = doc.addBlock(
'affine:paragraph',
{
text: new Text('text2'),
},
databaseBlockId
);
updateCell(db, p1, {
columnId: col1,
value: 0.1,
});
updateCell(db, p2, {
columnId: col2,
value: [selection[1]],
});
});
test('getColumn', () => {
const column = {
...databaseBlockProperties.numberColumnConfig.create('testColumnId'),
id: 'testColumnId',
};
addProperty(db, 'end', column);
const result = getProperty(db, column.id);
expect(result).toEqual(column);
});
test('addColumn', () => {
const column =
databaseBlockProperties.numberColumnConfig.create('Test Column');
const id = addProperty(db, 'end', column);
const result = getProperty(db, id);
expect(result).toMatchObject(column);
expect(result).toHaveProperty('id');
});
test('deleteColumn', () => {
const column = {
...databaseBlockProperties.numberColumnConfig.create('Test Column'),
id: 'testColumnId',
};
addProperty(db, 'end', column);
expect(getProperty(db, column.id)).toEqual(column);
deleteColumn(db, column.id);
expect(getProperty(db, column.id)).toBeUndefined();
});
test('getCell', () => {
const modelId = doc.addBlock(
'affine:paragraph',
{
text: new Text('paragraph'),
},
noteBlockId
);
const column = {
...databaseBlockProperties.numberColumnConfig.create('Test Column'),
id: 'testColumnId',
};
const cell: CellDataType = {
columnId: column.id,
value: 42,
};
addProperty(db, 'end', column);
updateCell(db, modelId, cell);
const model = doc.getModelById(modelId);
expect(model).not.toBeNull();
const result = getCell(db, model!.id, column.id);
expect(result).toEqual(cell);
});
test('updateCell', () => {
const newRowId = doc.addBlock(
'affine:paragraph',
{
text: new Text('text3'),
},
databaseBlockId
);
updateCell(db, newRowId, {
columnId: col2,
value: [selection[2]],
});
const cell = getCell(db, newRowId, col2);
expect(cell).toEqual({
columnId: col2,
value: [selection[2]],
});
});
test('copyCellsByColumn', () => {
const newColId = addProperty(
db,
'end',
propertyModelPresets.selectPropertyModelConfig.create('Copied Select', {
options: selection,
})
);
copyCellsByProperty(db, col2, newColId);
const cell = getCell(db, p2, newColId);
expect(cell).toEqual({
columnId: newColId,
value: [selection[1]],
});
});
});

View File

@@ -0,0 +1,95 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { describe, expect, test } from 'vitest';
import { insertUrlTextSegments } from '../../../../blocks/database/src/properties/paste-url.js';
type InsertCall = {
range: {
index: number;
length: number;
};
text: string;
attributes?: AffineTextAttributes;
};
describe('insertUrlTextSegments', () => {
test('should replace selected text on first insert and append remaining segments', () => {
const insertCalls: InsertCall[] = [];
const selectionCalls: Array<{ index: number; length: number } | null> = [];
const inlineEditor = {
insertText: (
range: { index: number; length: number },
text: string,
attributes?: AffineTextAttributes
) => {
insertCalls.push({ range, text, attributes });
},
setInlineRange: (range: { index: number; length: number } | null) => {
selectionCalls.push(range);
},
};
const inlineRange = { index: 4, length: 6 };
const segments = [
{ text: 'hi - ' },
{ text: 'https://google.com', link: 'https://google.com' },
];
insertUrlTextSegments(inlineEditor, inlineRange, segments);
expect(insertCalls).toEqual([
{
range: { index: 4, length: 6 },
text: 'hi - ',
},
{
range: { index: 9, length: 0 },
text: 'https://google.com',
attributes: {
link: 'https://google.com',
},
},
]);
expect(selectionCalls).toEqual([{ index: 27, length: 0 }]);
});
test('should keep insertion range length zero when there is no selected text', () => {
const insertCalls: InsertCall[] = [];
const selectionCalls: Array<{ index: number; length: number } | null> = [];
const inlineEditor = {
insertText: (
range: { index: number; length: number },
text: string,
attributes?: AffineTextAttributes
) => {
insertCalls.push({ range, text, attributes });
},
setInlineRange: (range: { index: number; length: number } | null) => {
selectionCalls.push(range);
},
};
const inlineRange = { index: 2, length: 0 };
const segments = [
{ text: 'prefix ' },
{ text: 'https://a.com', link: 'https://a.com' },
];
insertUrlTextSegments(inlineEditor, inlineRange, segments);
expect(insertCalls).toEqual([
{
range: { index: 2, length: 0 },
text: 'prefix ',
},
{
range: { index: 9, length: 0 },
text: 'https://a.com',
attributes: {
link: 'https://a.com',
},
},
]);
expect(selectionCalls).toEqual([{ index: 22, length: 0 }]);
});
});

View File

@@ -0,0 +1,79 @@
import { type SelectTag, t, typeSystem } from '@blocksuite/data-view';
import { describe, expect, test } from 'vitest';
describe('subtyping', () => {
test('all type is subtype of unknown', () => {
expect(typeSystem.unify(t.boolean.instance(), t.unknown.instance())).toBe(
true
);
expect(typeSystem.unify(t.string.instance(), t.unknown.instance())).toBe(
true
);
expect(
typeSystem.unify(
t.array.instance(t.string.instance()),
t.unknown.instance()
)
).toBe(true);
expect(typeSystem.unify(t.tag.instance(), t.unknown.instance())).toBe(true);
});
});
describe('function apply', () => {
test('generic type function', () => {
const fn = t.fn.instance(
[t.typeVarReference.create('A'), t.typeVarReference.create('A')],
t.boolean.instance(),
[t.typeVarDefine.create('A', t.unknown.instance())]
);
const instancedFn = typeSystem.instanceFn(
fn,
[t.boolean.instance()],
t.boolean.instance(),
{}
);
expect(instancedFn?.args[1]).toStrictEqual(t.boolean.instance());
});
test('tags infer', () => {
const fn = t.fn.instance(
[
t.typeVarReference.create('A'),
t.array.instance(t.typeVarReference.create('A')),
] as const,
t.boolean.instance(),
[t.typeVarDefine.create('A', t.tag.instance())]
);
const fnArray = t.fn.instance(
[
t.array.instance(t.typeVarReference.create('A')),
t.array.instance(t.typeVarReference.create('A')),
] as const,
t.boolean.instance(),
[t.typeVarDefine.create('A', t.tag.instance())]
);
const tags: SelectTag[] = [{ id: 'a', value: 'b', color: 'c' }];
const instancedFn = typeSystem.instanceFn(
fn,
[t.tag.instance(tags)],
t.boolean.instance(),
{}
);
const instancedFnArray = typeSystem.instanceFn(
fnArray,
[t.array.instance(t.tag.instance(tags))],
t.boolean.instance(),
{}
);
expect(
typeSystem.unify(
instancedFn?.args[1],
t.array.instance(t.tag.instance(tags))
)
).toBe(true);
expect(
typeSystem.unify(
instancedFnArray?.args[1],
t.array.instance(t.tag.instance(tags))
)
).toBe(true);
});
});

View File

@@ -0,0 +1,92 @@
import { describe, expect, test } from 'vitest';
import { bilibiliConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/bilibili.js';
import { excalidrawConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/excalidraw.js';
import { genericConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/generic.js';
import { googleDocsConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/google-docs.js';
import { googleDriveConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/google-drive.js';
import { miroConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/miro.js';
import { spotifyConfig } from '../../../blocks/embed/src/embed-iframe-block/configs/providers/spotify.js';
describe('embed iframe provider config', () => {
test('validates final iframe URLs from oEmbed providers', () => {
expect(
spotifyConfig.validateIframeUrl?.(
'https://open.spotify.com/embed/track/0TK2YIli7K1leLovkQiNik'
)
).toBe(true);
expect(
spotifyConfig.validateIframeUrl?.(
'https://example.com/embed/track/0TK2YIli7K1leLovkQiNik'
)
).toBe(false);
});
test('validates provider-specific iframe URL shapes', () => {
expect(
googleDriveConfig.validateIframeUrl?.(
'https://drive.google.com/file/d/file-id/preview?usp=embed_googleplus'
)
).toBe(true);
expect(
googleDriveConfig.validateIframeUrl?.(
'https://drive.google.com/drive/folders/folder-id?usp=sharing'
)
).toBe(false);
expect(
bilibiliConfig.validateIframeUrl?.(
'https://player.bilibili.com/player.html?bvid=BV1xx411c7mD&autoplay=0'
)
).toBe(true);
expect(
bilibiliConfig.match(
'https://player.bilibili.com/player.html?aid=123&autoplay=0'
)
).toBe(true);
expect(
bilibiliConfig.buildOEmbedUrl(
'https://player.bilibili.com/video/BV1xx411c7mD'
)
).toBe(
'https://player.bilibili.com/player.html?bvid=BV1xx411c7mD&autoplay=0'
);
expect(
bilibiliConfig.validateIframeUrl?.(
'https://www.bilibili.com/video/BV1xx411c7mD'
)
).toBe(false);
expect(
googleDocsConfig.validateIframeUrl?.(
'https://docs.google.com/document/d/doc-id/edit?usp=sharing'
)
).toBe(true);
expect(
miroConfig.validateIframeUrl?.(
'https://miro.com/app/live-embed/board-id/'
)
).toBe(true);
expect(
excalidrawConfig.validateIframeUrl?.('https://excalidraw.com/#room-id')
).toBe(true);
});
test('generic iframe validation excludes affine and non-https URLs', () => {
expect(genericConfig.validateIframeUrl?.('https://example.com/embed')).toBe(
true
);
expect(genericConfig.validateIframeUrl?.('http://example.com/embed')).toBe(
false
);
expect(
genericConfig.validateIframeUrl?.('https://app.affine.pro/embed')
).toBe(false);
expect(genericConfig.validateIframeUrl?.('https://127.0.0.1/embed')).toBe(
false
);
expect(genericConfig.validateIframeUrl?.('https://localhost/embed')).toBe(
false
);
});
});

View File

@@ -0,0 +1,770 @@
import { Bound } from '@blocksuite/global/gfx';
import { Viewport, viewportRuntimeConfig } from '@blocksuite/std/gfx';
import { afterEach, describe, expect, test, vi } from 'vitest';
import * as viewportModule from '../../../../../framework/std/src/gfx/viewport.js';
import * as viewportElementModule from '../../../../../framework/std/src/gfx/viewport-element.js';
import * as canvasRendererModule from '../../../../blocks/surface/src/renderer/canvas-renderer.js';
import {
paintPlaceholder,
syncCanvasSize,
} from '../../../../gfx/turbo-renderer/src/renderer-utils.js';
import type { ViewportLayoutTree } from '../../../../gfx/turbo-renderer/src/types.js';
const originalCaps = [...viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM];
const originalDevicePixelRatio = Object.getOwnPropertyDescriptor(
window,
'devicePixelRatio'
);
function setDevicePixelRatio(value: number) {
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value,
});
}
function createRect(width: number, height: number): DOMRect {
return {
width,
height,
left: 0,
top: 0,
right: width,
bottom: height,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
}
function createFakeBlockModel(
id: string,
x: number,
y: number,
w = 10,
h = 10
) {
return {
id,
elementBound: new Bound(x, y, w, h),
};
}
type PaintPlaceholderForTest = (
canvas: HTMLCanvasElement,
layout: ViewportLayoutTree,
viewport: {
zoom: number;
toViewCoord: (x: number, y: number) => [number, number];
}
) => void;
afterEach(() => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [...originalCaps];
if (originalDevicePixelRatio) {
Object.defineProperty(window, 'devicePixelRatio', originalDevicePixelRatio);
}
vi.restoreAllMocks();
});
describe('edgeless canvas budget', () => {
test('requests canvas budget sync when zoom crosses an effective dpr bucket', () => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
expect(
'shouldSyncCanvasBudgetOnViewportUpdate' in canvasRendererModule
).toBe(true);
const shouldSyncCanvasBudgetOnViewportUpdate = (
canvasRendererModule as {
shouldSyncCanvasBudgetOnViewportUpdate: (
previousZoom: number,
nextZoom: number,
rawDpr?: number
) => boolean;
}
).shouldSyncCanvasBudgetOnViewportUpdate;
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 2)).toBe(true);
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.75, 2)).toBe(false);
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.45, 0.4, 2)).toBe(false);
expect(shouldSyncCanvasBudgetOnViewportUpdate(0.95, 0.4, 1)).toBe(false);
});
test('enables low-zoom survival mode only for active iOS gestures', () => {
expect('shouldUseLowZoomSurvivalMode' in canvasRendererModule).toBe(true);
const shouldUseLowZoomSurvivalMode = (
canvasRendererModule as {
shouldUseLowZoomSurvivalMode: (
isIOS: boolean,
zoom: number,
gestureActive: boolean
) => boolean;
}
).shouldUseLowZoomSurvivalMode;
expect(shouldUseLowZoomSurvivalMode(true, 0.4, true)).toBe(true);
expect(shouldUseLowZoomSurvivalMode(true, 0.6, true)).toBe(false);
expect(shouldUseLowZoomSurvivalMode(true, 0.4, false)).toBe(false);
expect(shouldUseLowZoomSurvivalMode(false, 0.4, true)).toBe(false);
});
test('does not enable canvas placeholders for low-zoom panning without zooming', () => {
expect('shouldRenderCanvasPlaceholders' in canvasRendererModule).toBe(true);
const shouldRenderCanvasPlaceholders = (
canvasRendererModule as {
shouldRenderCanvasPlaceholders: (params: {
isIOS: boolean;
zoom: number;
isPanning: boolean;
isZooming: boolean;
skipRefreshDuringGesture: boolean;
turboEnabled: boolean;
}) => boolean;
}
).shouldRenderCanvasPlaceholders;
expect(
shouldRenderCanvasPlaceholders({
isIOS: true,
zoom: 0.4,
isPanning: true,
isZooming: false,
skipRefreshDuringGesture: true,
turboEnabled: true,
})
).toBe(false);
expect(
shouldRenderCanvasPlaceholders({
isIOS: true,
zoom: 0.4,
isPanning: false,
isZooming: true,
skipRefreshDuringGesture: true,
turboEnabled: true,
})
).toBe(true);
});
test('shares one bypass decision for placeholder and render paths only during the low-zoom iOS landscape gesture or recovery window', () => {
expect('getStackingCanvasBypassState' in canvasRendererModule).toBe(true);
expect(
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
).toBe(true);
const getStackingCanvasBypassState = (
canvasRendererModule as {
getStackingCanvasBypassState: (params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) => boolean;
}
).getStackingCanvasBypassState;
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
canvasRendererModule as {
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) => boolean;
}
).shouldBypassStackingCanvasesDuringLowZoomGesture;
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(true);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: false,
recoveryActive: true,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(true);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: false,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
expect(
shouldBypassStackingCanvasesDuringLowZoomGesture({
isIOS: true,
zoom: 0.4,
gestureActive: false,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 430,
viewportHeight: 932,
})
).toBe(false);
expect(
getStackingCanvasBypassState({
isIOS: true,
zoom: 0.6,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
expect(
getStackingCanvasBypassState({
isIOS: false,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
})
).toBe(false);
});
test('gesture low-zoom landscape bypass detaches stacking canvases through the existing attachment path', () => {
expect(
'shouldBypassStackingCanvasesDuringLowZoomGesture' in canvasRendererModule
).toBe(true);
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
true
);
const shouldBypassStackingCanvasesDuringLowZoomGesture = (
canvasRendererModule as {
shouldBypassStackingCanvasesDuringLowZoomGesture: (params: {
isIOS: boolean;
zoom: number;
gestureActive: boolean;
recoveryActive: boolean;
viewportWidth: number;
viewportHeight: number;
}) => boolean;
}
).shouldBypassStackingCanvasesDuringLowZoomGesture;
const getStackingCanvasAttachmentDiff = (
canvasRendererModule as {
getStackingCanvasAttachmentDiff: (params: {
canvases: HTMLCanvasElement[];
wasAttached: boolean;
shouldAttach: boolean;
}) => {
added: HTMLCanvasElement[];
removed: HTMLCanvasElement[];
};
}
).getStackingCanvasAttachmentDiff;
const canvases = [document.createElement('canvas')];
const shouldBypass = shouldBypassStackingCanvasesDuringLowZoomGesture({
isIOS: true,
zoom: 0.4,
gestureActive: true,
recoveryActive: false,
viewportWidth: 932,
viewportHeight: 430,
});
expect(shouldBypass).toBe(true);
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: true,
shouldAttach: !shouldBypass,
})
).toEqual({
added: [],
removed: canvases,
});
});
test('uses overscan for main-canvas fallback culling and render origin', () => {
expect('getMainCanvasFallbackBounds' in canvasRendererModule).toBe(true);
const getMainCanvasFallbackBounds = (
canvasRendererModule as {
getMainCanvasFallbackBounds: (params: {
viewportBounds: Bound;
overscanViewportBounds: Bound;
}) => {
cullBound: Bound;
renderBound: Bound;
};
}
).getMainCanvasFallbackBounds;
const viewportBounds = new Bound(100, 200, 300, 150);
const overscanViewportBounds = new Bound(40, 170, 420, 210);
expect(
getMainCanvasFallbackBounds({
viewportBounds,
overscanViewportBounds,
})
).toEqual({
cullBound: overscanViewportBounds,
renderBound: overscanViewportBounds,
});
});
test('lays out overscan canvases relative to the exact viewport', () => {
expect('getCanvasViewportLayout' in canvasRendererModule).toBe(true);
const getCanvasViewportLayout = (
canvasRendererModule as {
getCanvasViewportLayout: (params: {
bound: Bound;
viewportBounds: Bound;
zoom: number;
viewScale: number;
dpr: number;
}) => {
actualHeight: number;
actualWidth: number;
height: number;
transform: string;
width: number;
};
}
).getCanvasViewportLayout;
expect(
getCanvasViewportLayout({
bound: new Bound(40, 170, 420, 210),
viewportBounds: new Bound(100, 200, 300, 150),
zoom: 1,
viewScale: 1,
dpr: 2,
})
).toEqual({
actualHeight: 420,
actualWidth: 840,
height: 210,
transform: 'translate(-60px, -30px) scale(1)',
width: 420,
});
});
test('computes stacking canvas DOM attachment diffs when bypass toggles', () => {
expect('getStackingCanvasAttachmentDiff' in canvasRendererModule).toBe(
true
);
const getStackingCanvasAttachmentDiff = (
canvasRendererModule as {
getStackingCanvasAttachmentDiff: (params: {
canvases: HTMLCanvasElement[];
wasAttached: boolean;
shouldAttach: boolean;
}) => {
added: HTMLCanvasElement[];
removed: HTMLCanvasElement[];
};
}
).getStackingCanvasAttachmentDiff;
const canvasA = document.createElement('canvas');
const canvasB = document.createElement('canvas');
const canvases = [canvasA, canvasB];
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: true,
shouldAttach: false,
})
).toEqual({
added: [],
removed: canvases,
});
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: false,
shouldAttach: true,
})
).toEqual({
added: canvases,
removed: [],
});
expect(
getStackingCanvasAttachmentDiff({
canvases,
wasAttached: true,
shouldAttach: true,
})
).toEqual({
added: [],
removed: [],
});
});
test('emits a lightweight zoom signal during gesture-skipped zoom updates so canvas budgets can shrink', () => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
const viewport = new Viewport();
viewport.SKIP_REFRESH_DURING_GESTURE = true;
const viewportUpdated = vi.fn();
const zoomUpdates: Array<{ previousZoom: number; zoom: number }> = [];
let lastCanvasBudgetZoom = viewport.zoom;
let budgetSyncCount = 0;
viewport.viewportUpdated.subscribe(viewportUpdated);
expect('zoomUpdated' in viewport).toBe(true);
const zoomUpdated = (
viewport as unknown as {
zoomUpdated: {
subscribe: (
callback: (update: { previousZoom: number; zoom: number }) => void
) => void;
};
}
).zoomUpdated;
zoomUpdated.subscribe(update => {
zoomUpdates.push(update);
if (
(
canvasRendererModule as {
shouldSyncCanvasBudgetOnViewportUpdate: (
previousZoom: number,
nextZoom: number,
rawDpr?: number
) => boolean;
}
).shouldSyncCanvasBudgetOnViewportUpdate(
lastCanvasBudgetZoom,
update.zoom,
2
)
) {
budgetSyncCount += 1;
}
lastCanvasBudgetZoom = update.zoom;
});
viewport.panning$.next(true);
viewport.setZoom(0.4, { x: 0, y: 0 }, false, false, true);
expect(viewportUpdated).not.toHaveBeenCalled();
expect(zoomUpdates).toEqual([{ previousZoom: 1, zoom: 0.4 }]);
expect(budgetSyncCount).toBe(1);
viewport.dispose();
});
test('keeps programmatic setZoom on the normal viewport update path in skip mode', () => {
const viewport = new Viewport();
viewport.SKIP_REFRESH_DURING_GESTURE = true;
const viewportUpdated = vi.fn();
const zoomUpdated = vi.fn();
viewport.viewportUpdated.subscribe(viewportUpdated);
viewport.zoomUpdated.subscribe(zoomUpdated);
viewport.setZoom(0.4, { x: 0, y: 0 });
expect(viewportUpdated).toHaveBeenCalledTimes(1);
expect(zoomUpdated).toHaveBeenCalledWith({ previousZoom: 1, zoom: 0.4 });
expect(viewport.panning$.value).toBe(false);
expect(viewport.zooming$.value).toBe(false);
viewport.dispose();
});
test('enables low-zoom block survival only while the gesture is still active', () => {
expect('shouldUseLowZoomBlockSurvivalMode' in viewportElementModule).toBe(
true
);
const shouldUseLowZoomBlockSurvivalMode = (
viewportElementModule as {
shouldUseLowZoomBlockSurvivalMode: (params: {
zoom: number;
skipRefreshDuringGesture: boolean;
gestureActive: boolean;
}) => boolean;
}
).shouldUseLowZoomBlockSurvivalMode;
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: true,
gestureActive: true,
})
).toBe(true);
expect(
shouldUseLowZoomBlockSurvivalMode({
zoom: 0.4,
skipRefreshDuringGesture: true,
gestureActive: false,
})
).toBe(false);
});
test('keeps selected and one nearby viewport block active during low-zoom gesture survival', () => {
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
const getLowZoomGestureActiveModels = (
viewportElementModule as {
getLowZoomGestureActiveModels: (params: {
selectedModels: Set<{ id: string; elementBound: Bound }>;
viewportModels: Set<{ id: string; elementBound: Bound }>;
viewportBounds: Bound;
nearbyActiveBlockLimit: number;
nearbyDistanceRatio: number;
}) => Set<{ id: string; elementBound: Bound }>;
}
).getLowZoomGestureActiveModels;
const selected = createFakeBlockModel('selected', 10, 10);
const nearby = createFakeBlockModel('nearby', 28, 12);
const far = createFakeBlockModel('far', 78, 78);
const activeModels = getLowZoomGestureActiveModels({
selectedModels: new Set([selected]),
viewportModels: new Set([selected, nearby, far]),
viewportBounds: new Bound(0, 0, 100, 100),
nearbyActiveBlockLimit: 1,
nearbyDistanceRatio: 0.35,
});
expect([...activeModels].map(model => model.id).sort()).toEqual([
'nearby',
'selected',
]);
});
test('falls back to the nearest viewport block when nothing is selected', () => {
expect('getLowZoomGestureActiveModels' in viewportElementModule).toBe(true);
const getLowZoomGestureActiveModels = (
viewportElementModule as {
getLowZoomGestureActiveModels: (params: {
selectedModels: Set<{ id: string; elementBound: Bound }>;
viewportModels: Set<{ id: string; elementBound: Bound }>;
viewportBounds: Bound;
nearbyActiveBlockLimit: number;
nearbyDistanceRatio: number;
}) => Set<{ id: string; elementBound: Bound }>;
}
).getLowZoomGestureActiveModels;
const nearest = createFakeBlockModel('nearest', 46, 46);
const farther = createFakeBlockModel('farther', 78, 78);
const activeModels = getLowZoomGestureActiveModels({
selectedModels: new Set(),
viewportModels: new Set([nearest, farther]),
viewportBounds: new Bound(0, 0, 100, 100),
nearbyActiveBlockLimit: 1,
nearbyDistanceRatio: 0.35,
});
expect([...activeModels].map(model => model.id)).toEqual(['nearest']);
});
test('starts post-gesture recovery immediately once gesture signals fully settle', () => {
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
const getPostGestureRecoveryDelay = (
viewportModule as {
getPostGestureRecoveryDelay: (params: {
isPanning: boolean;
isZooming: boolean;
fallbackDelayMs: number;
}) => number;
}
).getPostGestureRecoveryDelay;
expect(
getPostGestureRecoveryDelay({
isPanning: false,
isZooming: false,
fallbackDelayMs: 220,
})
).toBe(0);
});
test('keeps fallback post-gesture delay while a gesture signal is still active', () => {
expect('getPostGestureRecoveryDelay' in viewportModule).toBe(true);
const getPostGestureRecoveryDelay = (
viewportModule as {
getPostGestureRecoveryDelay: (params: {
isPanning: boolean;
isZooming: boolean;
fallbackDelayMs: number;
}) => number;
}
).getPostGestureRecoveryDelay;
expect(
getPostGestureRecoveryDelay({
isPanning: true,
isZooming: false,
fallbackDelayMs: 220,
})
).toBe(220);
expect(
getPostGestureRecoveryDelay({
isPanning: false,
isZooming: true,
fallbackDelayMs: 220,
})
).toBe(220);
});
test('sizes turbo renderer canvas with effective dpr at low zoom', () => {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
setDevicePixelRatio(2);
const canvas = document.createElement('canvas');
const host = document.createElement('div');
vi.spyOn(host, 'getBoundingClientRect').mockReturnValue(
createRect(200, 100)
);
(
syncCanvasSize as unknown as (
canvas: HTMLCanvasElement,
host: HTMLElement,
zoom: number
) => void
)(canvas, host, 0.4);
expect(canvas.width).toBe(200);
expect(canvas.height).toBe(100);
(
syncCanvasSize as unknown as (
canvas: HTMLCanvasElement,
host: HTMLElement,
zoom: number
) => void
)(canvas, host, 0.95);
expect(canvas.width).toBe(400);
expect(canvas.height).toBe(200);
});
test('paints turbo placeholders with effective dpr at low zoom', () => {
const previousTheme = document.documentElement.dataset.theme;
document.documentElement.dataset.theme = 'light';
try {
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
setDevicePixelRatio(2);
const canvas = document.createElement('canvas');
const fillRect = vi.fn();
const strokeRect = vi.fn();
let fillStyle = '';
let strokeStyle = '';
vi.spyOn(canvas, 'getContext').mockReturnValue({
get fillStyle() {
return fillStyle;
},
set fillStyle(value: string) {
fillStyle = value;
},
get strokeStyle() {
return strokeStyle;
},
set strokeStyle(value: string) {
strokeStyle = value;
},
fillRect,
strokeRect,
} as unknown as CanvasRenderingContext2D);
const layout: ViewportLayoutTree = {
roots: [
{
blockId: 'root',
type: 'affine:page',
layout: {
blockId: 'root',
type: 'affine:page',
rect: { x: 0, y: 0, w: 50, h: 20 },
},
children: [],
},
],
overallRect: { x: 0, y: 0, w: 50, h: 20 },
};
const paintPlaceholderForTest =
paintPlaceholder as unknown as PaintPlaceholderForTest;
paintPlaceholderForTest(canvas, layout, {
zoom: 0.4,
toViewCoord: () => [0, 0],
});
expect(fillStyle).toBe('rgba(0, 0, 0, 0.04)');
expect(strokeStyle).toBe('rgba(0, 0, 0, 0.02)');
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 20, 8);
paintPlaceholderForTest(canvas, layout, {
zoom: 0.95,
toViewCoord: () => [0, 0],
});
expect(fillRect).toHaveBeenLastCalledWith(0, 0, 95, 38);
} finally {
document.documentElement.dataset.theme = previousTheme;
}
});
});

View File

@@ -0,0 +1,34 @@
import { ColorScheme } from '@blocksuite/affine-model';
import { describe, expect, it } from 'vitest';
import {
getAffinePlaceholderFillColor,
getAffinePlaceholderStrokeColor,
inferColorSchemeFromThemeMode,
} from '../../../../shared/src/theme/placeholder-style.js';
describe('affine placeholder style', () => {
it('returns subtle light placeholder colors', () => {
expect(getAffinePlaceholderFillColor(ColorScheme.Light)).toBe(
'rgba(0, 0, 0, 0.04)'
);
expect(getAffinePlaceholderStrokeColor(ColorScheme.Light)).toBe(
'rgba(0, 0, 0, 0.02)'
);
});
it('returns subtle dark placeholder colors', () => {
expect(getAffinePlaceholderFillColor(ColorScheme.Dark)).toBe(
'rgba(255, 255, 255, 0.08)'
);
expect(getAffinePlaceholderStrokeColor(ColorScheme.Dark)).toBe(
'rgba(255, 255, 255, 0.04)'
);
});
it('infers color scheme from theme mode', () => {
expect(inferColorSchemeFromThemeMode('dark')).toBe(ColorScheme.Dark);
expect(inferColorSchemeFromThemeMode('light')).toBe(ColorScheme.Light);
expect(inferColorSchemeFromThemeMode('')).toBe(ColorScheme.Light);
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, test } from 'vitest';
import * as turboRendererModule from '../../../../gfx/turbo-renderer/src/turbo-renderer.js';
describe('viewport turbo renderer policy', () => {
test.each([
{ isIOS: true, zoom: 0.4, hasBitmap: true, expected: true },
{ isIOS: true, zoom: 0.4, hasBitmap: false, expected: false },
{ isIOS: false, zoom: 0.4, hasBitmap: true, expected: false },
{ isIOS: true, zoom: 0.8, hasBitmap: true, expected: false },
])(
'prefers cached bitmap only for iOS low-zoom gestures with a bitmap %#',
({ isIOS, zoom, hasBitmap, expected }) => {
expect(
'shouldPreferBitmapCacheDuringLowZoomGesture' in turboRendererModule
).toBe(true);
const shouldPreferBitmapCacheDuringLowZoomGesture = (
turboRendererModule as {
shouldPreferBitmapCacheDuringLowZoomGesture: (params: {
isIOS: boolean;
zoom: number;
hasBitmap: boolean;
}) => boolean;
}
).shouldPreferBitmapCacheDuringLowZoomGesture;
expect(
shouldPreferBitmapCacheDuringLowZoomGesture({
isIOS,
zoom,
hasBitmap,
})
).toBe(expected);
}
);
test.each([
{ isIOS: true, zoom: 0.4, expected: false },
{ isIOS: true, zoom: 0.8, expected: true },
{ isIOS: false, zoom: 0.4, expected: true },
])(
'idles turbo blocks outside iOS low-zoom survival mode %#',
({ isIOS, zoom, expected }) => {
expect('shouldIdleTurboBlocksDuringZooming' in turboRendererModule).toBe(
true
);
const shouldIdleTurboBlocksDuringZooming = (
turboRendererModule as {
shouldIdleTurboBlocksDuringZooming: (params: {
isIOS: boolean;
zoom: number;
}) => boolean;
}
).shouldIdleTurboBlocksDuringZooming;
expect(
shouldIdleTurboBlocksDuringZooming({
isIOS,
zoom,
})
).toBe(expected);
}
);
});

View File

@@ -0,0 +1,42 @@
import { defaultImageProxyMiddleware } from '@blocksuite/affine-shared/adapters';
import {
Schema,
Transformer,
type TransformerMiddleware,
} from '@blocksuite/store';
import { TestWorkspace } from '@blocksuite/store/test';
import { AffineSchemas } from '../../schemas.js';
import { testStoreExtensions } from './store.js';
declare global {
interface Window {
happyDOM: {
settings: {
fetch: {
disableSameOriginPolicy: boolean;
};
};
};
}
}
export function createJob(middlewares?: TransformerMiddleware[]) {
window.happyDOM.settings.fetch.disableSameOriginPolicy = true;
const testMiddlewares = middlewares ?? [];
testMiddlewares.push(defaultImageProxyMiddleware);
const schema = new Schema().register(AffineSchemas);
const docCollection = new TestWorkspace();
docCollection.storeExtensions = testStoreExtensions;
docCollection.meta.initialize();
return new Transformer({
schema,
blobCRUD: docCollection.blobSync,
middlewares: testMiddlewares,
docCRUD: {
create: (id: string) => docCollection.createDoc(id).getStore({ id }),
get: (id: string) => docCollection.getDoc(id)?.getStore({ id }) ?? null,
delete: (id: string) => docCollection.removeDoc(id),
},
});
}

View File

@@ -0,0 +1,12 @@
import { Container } from '@blocksuite/global/di';
import { testStoreExtensions } from './store';
export function getProvider() {
const container = new Container();
const exts = testStoreExtensions;
exts.forEach(ext => {
ext.setup(container);
});
return container.provider();
}

View File

@@ -0,0 +1,34 @@
import type { BlockSnapshot, SliceSnapshot } from '@blocksuite/store';
export function nanoidReplacement(snapshot: BlockSnapshot | SliceSnapshot) {
return JSON.parse(nanoidReplacementString(JSON.stringify(snapshot)));
}
const escapedSnapshotAttributes = new Set([
'"attributes"',
'"conditions"',
'"iconColumn"',
'"background"',
'"LinkedPage"',
'"elementIds"',
'"attachment"',
]);
function nanoidReplacementString(snapshotString: string) {
const re =
/("block:[A-Za-z0-9-_]{10}")|("[A-Za-z0-9-_]{10}")|("[A-Za-z0-9-_]{35}")|("[A-Za-z0-9-_]{10}:[A-Za-z0-9-_]{10}")|("var\(--affine-v2-chip-label-[a-z]{3,10}\)")|("[A-Za-z0-9-_=]{44}")/g;
const matches = snapshotString.matchAll(re);
const matchesReplaceMap = new Map();
let escapedNumber = 0;
Array.from(matches).forEach((match, index) => {
if (escapedSnapshotAttributes.has(match[0])) {
matchesReplaceMap.set(match[0], match[0]);
escapedNumber++;
} else {
matchesReplaceMap.set(
match[0],
`"matchesReplaceMap[${index - escapedNumber}]"`
);
}
});
return snapshotString.replace(re, match => matchesReplaceMap.get(match));
}

View File

@@ -0,0 +1,7 @@
import { StoreExtensionManager } from '@blocksuite/affine-ext-loader';
import { getInternalStoreExtensions } from '../../extensions/store';
const manager = new StoreExtensionManager(getInternalStoreExtensions());
export const testStoreExtensions = manager.get('store');