editor: set up integration tests

This commit is contained in:
Abdullah Atta
2023-02-27 10:57:27 +05:00
committed by Abdullah Atta
parent 912e97dc3c
commit 16ffec1f1e
11 changed files with 13478 additions and 63 deletions

View File

@@ -21,4 +21,7 @@ packages/editor/styles/
packages/editor/languages/ packages/editor/languages/
# editor mobile # editor mobile
packages/editor-mobile/build.bundle packages/editor-mobile/build.bundle
# snapshots
tap-snapshots

3
.gitignore vendored
View File

@@ -6,4 +6,5 @@ dist
nx-cloud.env nx-cloud.env
.idea .idea
.eslintcache .eslintcache
.env.local .env.local
.nyc_output

File diff suppressed because it is too large Load Diff

View File

@@ -28,8 +28,8 @@
"@tiptap/extension-text-align": "^2.0.0-beta.218", "@tiptap/extension-text-align": "^2.0.0-beta.218",
"@tiptap/extension-text-style": "^2.0.0-beta.218", "@tiptap/extension-text-style": "^2.0.0-beta.218",
"@tiptap/extension-underline": "^2.0.0-beta.218", "@tiptap/extension-underline": "^2.0.0-beta.218",
"@tiptap/starter-kit": "^2.0.0-beta.218",
"@tiptap/pm": "^2.0.0-beta.218", "@tiptap/pm": "^2.0.0-beta.218",
"@tiptap/starter-kit": "^2.0.0-beta.218",
"detect-indent": "^7.0.0", "detect-indent": "^7.0.0",
"katex": "^0.16.2", "katex": "^0.16.2",
"prism-themes": "^1.9.0", "prism-themes": "^1.9.0",
@@ -45,17 +45,24 @@
"zustand": "^3.7.2" "zustand": "^3.7.2"
}, },
"devDependencies": { "devDependencies": {
"@happy-dom/global-registrator": "^8.9.0",
"@mdi/js": "^6.9.96", "@mdi/js": "^6.9.96",
"@mdi/react": "^1.6.0", "@mdi/react": "^1.6.0",
"@swc/core": "^1.3.36",
"@types/katex": "^0.14.0", "@types/katex": "^0.14.0",
"@types/prismjs": "^1.26.0", "@types/prismjs": "^1.26.0",
"@types/react": "17.0.2", "@types/react": "17.0.2",
"@types/react-color": "^3.0.6", "@types/react-color": "^3.0.6",
"@types/react-dom": "17.0.2", "@types/react-dom": "17.0.2",
"@types/react-modal": "^3.13.1", "@types/react-modal": "^3.13.1",
"@types/tap": "^15.0.8",
"@types/tinycolor2": "^1.4.3", "@types/tinycolor2": "^1.4.3",
"expect": "^29.4.3",
"framer-motion": "^6.5.1", "framer-motion": "^6.5.1",
"isomorphic-fetch": "^3.0.0", "isomorphic-fetch": "^3.0.0",
"prosemirror-test-builder": "^1.1.0",
"tap": "^16.3.4",
"tsconfig-paths": "^3.14.2",
"typescript": "^4.8.2", "typescript": "^4.8.2",
"web-vitals": "^2.1.4", "web-vitals": "^2.1.4",
"zx": "^7.0.8" "zx": "^7.0.8"
@@ -67,6 +74,7 @@
"react-dom": ">=17.0.0" "react-dom": ">=17.0.0"
}, },
"scripts": { "scripts": {
"test": "TS_NODE_PROJECT=tsconfig.tests.json tap --ts --no-check-coverage --test-env=NODE_ENV=test src/**/*.test.ts",
"prebuild": "zx ./scripts/build.mjs", "prebuild": "zx ./scripts/build.mjs",
"prewatch": "zx ./scripts/build.mjs", "prewatch": "zx ./scripts/build.mjs",
"build": "tsc", "build": "tsc",

View File

@@ -103,8 +103,9 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
private renderReactComponent( private renderReactComponent(
component: () => React.ReactElement<unknown> | null component: () => React.ReactElement<unknown> | null
) { ) {
if (process.env.NODE_ENV === "test") return;
if (!this.domRef || !component || !this.portalProviderAPI) { if (!this.domRef || !component || !this.portalProviderAPI) {
console.warn("Cannot render node view", this.editor.storage); console.warn("Cannot render node view");
return; return;
} }

View File

@@ -0,0 +1,247 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import "@/tests.setup";
import tap from "tap";
import expect from "expect";
import { Editor, AnyExtension, Extensions } from "@tiptap/core";
import { TaskListNode } from "../index";
import { TaskItemNode } from "../../task-item";
import StarterKit from "@tiptap/starter-kit";
import { builders, doc, NodeBuilder, p, eq } from "prosemirror-test-builder";
import { Node, Schema } from "@tiptap/pm/model";
import { countCheckedItems, deleteCheckedItems, sortList } from "../utils";
import { EditorState } from "@tiptap/pm/state";
type Builder<TNodes extends string> = {
scheme: Schema;
} & Record<TNodes, NodeBuilder>;
type EditorOptions<TNodes extends string> = {
extensions: Record<TNodes, AnyExtension>;
initialDoc?: (builder: Builder<TNodes>) => Node;
};
function createEditor<TNodes extends string>(options: EditorOptions<TNodes>) {
const { extensions, initialDoc } = options;
const editor = new Editor({
extensions: [StarterKit, ...(Object.values(extensions) as Extensions)]
});
const builder = builders(editor.schema) as unknown as Builder<TNodes>;
if (initialDoc) {
const doc = initialDoc(builder);
editor.view.updateState(
EditorState.create({
schema: editor.view.state.schema,
doc,
plugins: editor.state.plugins
})
);
return { editor, builder, initialDoc: doc };
}
return { editor, builder, initialDoc: editor.state.doc };
}
tap.test(`count items in a task list`, async () => {
const {
builder: { taskItem, taskList }
} = createEditor({
extensions: {
taskItem: TaskItemNode,
taskList: TaskListNode
}
});
const taskListNode = taskList(
taskItem({ checked: true }, p("Task item 1")),
taskItem({ checked: false }, p("Task item 2")),
taskItem(
{ checked: false },
p("Task item 3"),
taskList(taskItem({ checked: true }, p("Task item 4")))
)
);
const { checked, total } = countCheckedItems(taskListNode);
expect(checked).toBe(2);
expect(total).toBe(4);
});
tap.test(`delete checked items in a task list`, async (t) => {
const { editor } = createEditor({
initialDoc: ({ taskItem, taskList }) =>
doc(
taskList(
taskItem({ checked: true }, p("Task item 1")),
taskItem({ checked: false }, p("Task item 2"))
)
),
extensions: {
taskItem: TaskItemNode,
taskList: TaskListNode
}
});
let { tr } = editor.state;
tr = deleteCheckedItems(tr, 0) || tr;
editor.view.dispatch(tr);
t.matchSnapshot(editor.state.doc.content.toJSON());
});
tap.test(`delete checked items in a nested task list`, async (t) => {
const { editor } = createEditor({
initialDoc: ({ taskItem, taskList }) =>
doc(
taskList(
taskItem({ checked: true }, p("Task item 1")),
taskItem({ checked: false }, p("Task item 2")),
taskItem(
{ checked: false },
p("Task item 3"),
taskList(
taskItem({ checked: true }, p("Task item 4")),
taskItem({ checked: false }, p("Task item 5")),
taskItem({ checked: false }, p("Task item 6")),
taskItem(
{ checked: true },
p("Task item 7"),
taskList(
taskItem({ checked: true }, p("Task item 8")),
taskItem({ checked: true }, p("Task item 9")),
taskItem({ checked: false }, p("Task item 10")),
taskItem({ checked: true }, p("Task item 11"))
)
)
)
)
)
),
extensions: {
taskItem: TaskItemNode,
taskList: TaskListNode
}
});
let { tr } = editor.state;
tr = deleteCheckedItems(tr, 0) || tr;
editor.view.dispatch(tr);
t.matchSnapshot(editor.state.doc.content.toJSON());
});
tap.test(
`delete checked items in a task list with no checked items should do nothing`,
async () => {
const { editor, initialDoc } = createEditor({
initialDoc: ({ taskItem, taskList }) =>
doc(
taskList(
taskItem({ checked: false }, p("Task item 2")),
taskItem(
{ checked: false },
p("Task item 3"),
taskList(
taskItem({ checked: false }, p("Task item 5")),
taskItem({ checked: false }, p("Task item 6")),
taskItem(
{ checked: false },
p("Task item 7"),
taskList(taskItem({ checked: false }, p("Task item 10")))
)
)
)
)
),
extensions: {
taskItem: TaskItemNode,
taskList: TaskListNode
}
});
editor.commands.command(({ tr }) => !!deleteCheckedItems(tr, 0));
expect(eq(editor.state.doc, initialDoc)).toBe(true);
}
);
tap.test(`sort checked items to the bottom of the task list`, async (t) => {
const { editor } = createEditor({
initialDoc: ({ taskItem, taskList }) =>
doc(
taskList(
taskItem({ checked: true }, p("Task item 1")),
taskItem({ checked: false }, p("Task item 2")),
taskItem(
{ checked: false },
p("Task item 3"),
taskList(
taskItem({ checked: true }, p("Task item 4")),
taskItem({ checked: true }, p("Task item 5")),
taskItem({ checked: false }, p("Task item 6")),
taskItem(
{ checked: false },
p("Task item 7"),
taskList(
taskItem({ checked: true }, p("Task item 8")),
taskItem({ checked: true }, p("Task item 9")),
taskItem({ checked: false }, p("Task item 10")),
taskItem({ checked: true }, p("Task item 11"))
)
)
)
)
)
),
extensions: {
taskItem: TaskItemNode,
taskList: TaskListNode
}
});
editor.commands.command(({ tr }) => !!sortList(tr, 0));
t.matchSnapshot(editor.state.doc.content.toJSON());
});
tap.test(
`sorting a task list with no checked items should do nothing`,
async () => {
const { editor, initialDoc } = createEditor({
initialDoc: ({ taskItem, taskList }) =>
doc(
taskList(
taskItem({ checked: false }, p("Task item 1")),
taskItem({ checked: false }, p("Task item 2"))
)
),
extensions: {
taskItem: TaskItemNode,
taskList: TaskListNode
}
});
editor.commands.command(({ tr }) => !!sortList(tr, 0));
expect(eq(editor.state.doc, initialDoc)).toBe(true);
}
);

View File

@@ -57,11 +57,13 @@ export function deleteCheckedItems(tr: Transaction, pos: number) {
listNode.forEach((node, _, index) => { listNode.forEach((node, _, index) => {
if (!node.attrs.checked) children.push(listNode.child(index)); if (!node.attrs.checked) children.push(listNode.child(index));
}); });
// if all items are unchecked, skip
if (children.length === listNode.childCount) continue;
tr.replaceWith( tr.replaceWith(
tr.mapping.map(list.pos), tr.mapping.map(list.pos + 1),
tr.mapping.map(list.pos + list.node.nodeSize), tr.mapping.map(list.pos + list.node.nodeSize - 1),
listNode.copy(Fragment.from(children)) Fragment.from(children)
); );
} }
@@ -95,10 +97,16 @@ export function sortList(tr: Transaction, pos: number) {
checked: node.attrs.checked ? 1 : 0 checked: node.attrs.checked ? 1 : 0
}); });
}); });
// if every item is checked or unchecked, skip
if (
children.every((a) => a.checked === 1) ||
children.every((a) => a.checked === 0)
)
continue;
tr.replaceWith( tr.replaceWith(
tr.mapping.map(list.pos), tr.mapping.map(list.pos + 1),
tr.mapping.map(list.pos + list.node.nodeSize), tr.mapping.map(list.pos + list.node.nodeSize - 1),
Fragment.from( Fragment.from(
children children
.sort((a, b) => a.checked - b.checked) .sort((a, b) => a.checked - b.checked)

View File

@@ -0,0 +1,341 @@
/* IMPORTANT
* This snapshot file is auto-generated, but designed for humans.
* It should be checked into source control and tracked carefully.
* Re-generate by setting TAP_SNAPSHOT=1 and running tests.
* Make sure to inspect the output below. Do not ignore changes!
*/
'use strict'
exports[`src/extensions/task-list/tests/task-list.test.ts TAP delete checked items in a nested task list > must match snapshot 1`] = `
Array [
Object {
"attrs": Null Object {
"title": null,
},
"content": Array [
Object {
"attrs": Null Object {
"checked": false,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 2",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "taskItem",
},
Object {
"attrs": Null Object {
"checked": false,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 3",
"type": "text",
},
],
"type": "paragraph",
},
Object {
"attrs": Null Object {
"title": null,
},
"content": Array [
Object {
"attrs": Null Object {
"checked": false,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 5",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "taskItem",
},
Object {
"attrs": Null Object {
"checked": false,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 6",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "taskItem",
},
],
"type": "taskList",
},
],
"type": "taskItem",
},
],
"type": "taskList",
},
]
`
exports[`src/extensions/task-list/tests/task-list.test.ts TAP delete checked items in a task list > must match snapshot 1`] = `
Array [
Object {
"attrs": Null Object {
"title": null,
},
"content": Array [
Object {
"attrs": Null Object {
"checked": false,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 2",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "taskItem",
},
],
"type": "taskList",
},
]
`
exports[`src/extensions/task-list/tests/task-list.test.ts TAP sort checked items to the bottom of the task list > must match snapshot 1`] = `
Array [
Object {
"attrs": Null Object {
"title": null,
},
"content": Array [
Object {
"attrs": Null Object {
"checked": false,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 2",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "taskItem",
},
Object {
"attrs": Null Object {
"checked": false,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 3",
"type": "text",
},
],
"type": "paragraph",
},
Object {
"attrs": Null Object {
"title": null,
},
"content": Array [
Object {
"attrs": Null Object {
"checked": true,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 4",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "taskItem",
},
Object {
"attrs": Null Object {
"checked": true,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 5",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "taskItem",
},
Object {
"attrs": Null Object {
"checked": false,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 6",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "taskItem",
},
Object {
"attrs": Null Object {
"checked": false,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 7",
"type": "text",
},
],
"type": "paragraph",
},
Object {
"attrs": Null Object {
"title": null,
},
"content": Array [
Object {
"attrs": Null Object {
"checked": true,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 8",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "taskItem",
},
Object {
"attrs": Null Object {
"checked": true,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 9",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "taskItem",
},
Object {
"attrs": Null Object {
"checked": false,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 10",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "taskItem",
},
Object {
"attrs": Null Object {
"checked": true,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 11",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "taskItem",
},
],
"type": "taskList",
},
],
"type": "taskItem",
},
],
"type": "taskList",
},
],
"type": "taskItem",
},
Object {
"attrs": Null Object {
"checked": true,
},
"content": Array [
Object {
"content": Array [
Object {
"text": "Task item 1",
"type": "text",
},
],
"type": "paragraph",
},
],
"type": "taskItem",
},
],
"type": "taskList",
},
]
`

View File

@@ -0,0 +1,22 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register();

View File

@@ -3,9 +3,7 @@
"compilerOptions": { "compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"], "lib": ["DOM", "DOM.Iterable", "ESNext"],
"jsx": "react-jsx", "jsx": "react-jsx",
"outDir": "./dist/", "outDir": "./dist/"
"experimentalDecorators": true,
"useDefineForClassFields": false
}, },
"include": ["src/"] "include": ["src/"]
} }

View File

@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig",
"compilerOptions": {
"module": "commonjs",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"jsx": "react-jsx",
"outDir": "./dist/",
"baseUrl": "./",
"paths": {
"@/*": ["*"]
}
},
"ts-node": {
"transpileOnly": true,
"swc": true,
"require": ["tsconfig-paths/register"]
},
"include": ["src/", "index.ts"]
}