chore: 初始化 BlockFlow Workbench 仓库

建立前端与 Tauri 桌面端的首个版本提交,包含核心编辑器、项目文件读写、测试与构建配置。

补充 Git 忽略规则和换行规范,排除依赖、构建产物、本地运行日志与临时验证文件,方便在其他电脑继续开发。
This commit is contained in:
2026-05-29 17:23:43 +08:00
commit 589ff15213
88 changed files with 31656 additions and 0 deletions

256
tests/project.test.ts Normal file
View File

@@ -0,0 +1,256 @@
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { afterEach, describe, expect, it } from "vitest"
import type { BlockNode, DataSetDocument, FragmentDocument, InlineNode, TemplateDocument } from "../src/core/types"
import { nodeProjectFileSystem } from "../src/project/nodeFileSystem"
import { getProjectPaths, joinPath, normalizePath, toRelativePath } from "../src/project/paths"
import {
createProject,
openProject,
readDocument,
scanProject,
writeDocument
} from "../src/project"
const tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })))
tempRoots.length = 0
})
describe("project folder system", () => {
it("normalizes project paths without Node path helpers", () => {
expect(normalizePath("D:\\Projects\\BlockFlow\\templates\\..\\fragments")).toBe("D:/Projects/BlockFlow/fragments")
expect(joinPath("D:\\Projects\\BlockFlow", "templates", "main.json")).toBe("D:/Projects/BlockFlow/templates/main.json")
expect(toRelativePath("D:\\Projects\\BlockFlow", "D:\\Projects\\BlockFlow\\templates\\main.json")).toBe("templates/main.json")
expect(getProjectPaths("D:\\Projects\\BlockFlow").fragmentsDir).toBe("D:/Projects/BlockFlow/fragments")
})
it("creates a minimal project folder", async () => {
const root = await makeTempRoot()
const result = await createProject(root, {
id: "project_test",
name: "Test Project",
entryTemplateId: "template_main",
includeExportsDir: true,
fs: nodeProjectFileSystem,
now: () => new Date("2026-05-24T00:00:00.000Z")
})
await expectPathExists(path.join(root, "project.json"))
await expectPathExists(path.join(root, "templates"))
await expectPathExists(path.join(root, "fragments"))
await expectPathExists(path.join(root, "datasets"))
await expectPathExists(path.join(root, "exports"))
expect(result.project.id).toBe("project_test")
const projectJson = JSON.parse(await fs.readFile(path.join(root, "project.json"), "utf8")) as {
paths?: unknown
entryTemplateId?: string
}
expect(projectJson.paths).toBeUndefined()
expect(projectJson.entryTemplateId).toBe("template_main")
})
it("opens templates, fragments, datasets, and resource tree entries", async () => {
const root = await makeProjectWithResources()
const snapshot = await openProject(root, { fs: nodeProjectFileSystem })
expect(snapshot.project.entryTemplateId).toBe("template_main")
expect(Object.keys(snapshot.templates)).toEqual(["template_main"])
expect(Object.keys(snapshot.fragments)).toEqual(["fragment_footer"])
expect(Object.keys(snapshot.datasets)).toEqual(["dataset_default"])
expect(snapshot.templates.template_main?.filePath).toContain("templates/main.json")
expect(snapshot.datasets.dataset_default?.data).toEqual({ user: { name: "张三" } })
const templateFile = findResource(snapshot.resourceTree.children, "templates/main.json")
expect(templateFile).toMatchObject({
type: "file",
resourceKind: "template",
documentId: "template_main"
})
})
it("detects missing fragment references", async () => {
const root = await makeProjectWithResources({
templateChildren: [{ id: "missing_ref", type: "fragmentRef", fragmentId: "missing" }]
})
const snapshot = await openProject(root, { fs: nodeProjectFileSystem })
expect(snapshot.referenceIndex.missingFragments).toContainEqual(
expect.objectContaining({ fromId: "template_main", fragmentId: "missing" })
)
expect(snapshot.diagnostics).toContainEqual(
expect.objectContaining({ code: "MISSING_FRAGMENT", fragmentId: "missing" })
)
})
it("skips invalid resource JSON and returns diagnostics", async () => {
const root = await makeProjectWithResources()
await fs.writeFile(path.join(root, "templates", "broken.json"), "{ nope", "utf8")
const snapshot = await openProject(root, { fs: nodeProjectFileSystem })
expect(snapshot.templates.template_main).toBeDefined()
expect(snapshot.templates.broken).toBeUndefined()
expect(snapshot.diagnostics).toContainEqual(
expect.objectContaining({ code: "INVALID_JSON" })
)
})
it("detects duplicate document ids across scanned files", async () => {
const root = await makeProjectWithResources()
await writeJson(path.join(root, "fragments", "duplicate.json"), {
kind: "fragment",
id: "template_main",
name: "Duplicate",
children: []
})
const snapshot = await openProject(root, { fs: nodeProjectFileSystem })
expect(snapshot.diagnostics).toContainEqual(
expect.objectContaining({
code: "DUPLICATE_DOCUMENT_ID",
documentId: "template_main"
})
)
})
it("writes documents without persisting runtime filePath", async () => {
const root = await makeProjectWithResources()
const filePath = path.join(root, "templates", "main.json")
const document = await readDocument(filePath, { fs: nodeProjectFileSystem }) as TemplateDocument
document.children = [paragraph([{ type: "text", content: "更新" }])]
const writtenPath = await writeDocument(root, document, { fs: nodeProjectFileSystem })
expect(writtenPath).toBe(filePath)
const saved = JSON.parse(await fs.readFile(filePath, "utf8")) as { filePath?: unknown }
expect(saved.filePath).toBeUndefined()
const reopened = await openProject(root, { fs: nodeProjectFileSystem })
expect(reopened.templates.template_main?.children).toEqual(document.children)
})
it("writes new fragments to the fragments directory", async () => {
const root = await makeProjectWithResources()
const fragment: FragmentDocument = {
kind: "fragment",
id: "fragment_new",
name: "New Fragment",
children: [paragraph([{ type: "text", content: "New body" }])]
}
const writtenPath = await writeDocument(root, fragment, { fs: nodeProjectFileSystem })
expect(writtenPath).toContain("fragments/fragment_new.json")
const saved = JSON.parse(await fs.readFile(writtenPath, "utf8")) as { filePath?: unknown }
expect(saved.filePath).toBeUndefined()
const reopened = await openProject(root, { fs: nodeProjectFileSystem })
expect(reopened.fragments.fragment_new?.children).toEqual(fragment.children)
})
it("scans nested json resource files", async () => {
const root = await makeProjectWithResources()
await fs.mkdir(path.join(root, "templates", "nested"), { recursive: true })
await writeJson(path.join(root, "templates", "nested", "extra.json"), {
kind: "template",
id: "template_extra",
name: "Extra",
children: []
})
const scanned = await scanProject(root, { fs: nodeProjectFileSystem })
expect(scanned.resourceFiles).toContainEqual(
expect.objectContaining({
relativePath: "templates/nested/extra.json",
resourceKind: "template"
})
)
})
})
async function makeProjectWithResources(options: { templateChildren?: BlockNode[] } = {}): Promise<string> {
const root = await makeTempRoot()
await createProject(root, {
entryTemplateId: "template_main",
fs: nodeProjectFileSystem,
now: () => new Date("2026-05-24T00:00:00.000Z")
})
await writeJson(path.join(root, "templates", "main.json"), {
kind: "template",
id: "template_main",
name: "Main",
children: options.templateChildren ?? [
paragraph([{ type: "text", content: "正文" }]),
{ id: "ref_footer", type: "fragmentRef", fragmentId: "fragment_footer" }
]
})
await writeJson(path.join(root, "fragments", "footer.json"), {
kind: "fragment",
id: "fragment_footer",
name: "Footer",
children: [paragraph([{ type: "text", content: "以上。" }])]
})
await writeJson(path.join(root, "datasets", "default.json"), {
kind: "dataset",
id: "dataset_default",
name: "Default",
data: { user: { name: "张三" } }
} satisfies DataSetDocument)
return root
}
function paragraph(inlines: InlineNode[]): BlockNode {
return {
id: `p_${Math.random().toString(36).slice(2)}`,
type: "paragraph",
inlines
}
}
async function makeTempRoot(): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "blockflow-project-"))
tempRoots.push(root)
return root
}
async function writeJson(filePath: string, value: unknown): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8")
}
async function expectPathExists(filePath: string): Promise<void> {
await expect(fs.stat(filePath)).resolves.toBeDefined()
}
function findResource(
nodes: Array<{ type: string; relativePath: string; children?: unknown[] }>,
relativePath: string
): unknown {
for (const node of nodes) {
if (node.relativePath === relativePath) {
return node
}
if (Array.isArray(node.children)) {
const found = findResource(
node.children as Array<{ type: string; relativePath: string; children?: unknown[] }>,
relativePath
)
if (found !== undefined) {
return found
}
}
}
return undefined
}