Skip to content

Commit c6bb2cb

Browse files
eloklamorca-ide
andcommitted
test: dailynote database auto-row smoke test
Co-authored-by: Orca <help@stably.ai>
1 parent 533ac84 commit c6bb2cb

1 file changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
#!/usr/bin/env node
2+
// Smoke test: dailyNoteDatabaseID feature.
3+
// 1. create notebook A + doc with an Attribute View block, materialize the AV
4+
// 2. set notebook A conf dailyNoteDatabaseID = av block id
5+
// 3. createDailyNote twice (same day): first adds exactly one row bound to the note, second is idempotent
6+
// 4. notebook B without the setting behaves as before
7+
import {spawn} from "node:child_process";
8+
import fs from "node:fs";
9+
import net from "node:net";
10+
import os from "node:os";
11+
import path from "node:path";
12+
import process from "node:process";
13+
import {fileURLToPath} from "node:url";
14+
15+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
16+
const root = path.resolve(scriptDir, "..");
17+
const appDir = path.join(root, "app");
18+
const kernelBinary = path.join(appDir, "kernel", "SiYuan-Kernel");
19+
const expectedKernelVersion = JSON.parse(fs.readFileSync(path.join(appDir, "package.json"), "utf8")).version;
20+
21+
const fail = (message) => {
22+
throw new Error(message);
23+
};
24+
25+
const nodeID = () => {
26+
const now = new Date();
27+
const pad = (value, size = 2) => String(value).padStart(size, "0");
28+
const stamp = [
29+
now.getFullYear(),
30+
pad(now.getMonth() + 1),
31+
pad(now.getDate()),
32+
pad(now.getHours()),
33+
pad(now.getMinutes()),
34+
pad(now.getSeconds()),
35+
].join("");
36+
const random = Math.random().toString(36).slice(2, 9).padEnd(7, "0");
37+
return `${stamp}-${random}`;
38+
};
39+
40+
const getFreePort = () => new Promise((resolve, reject) => {
41+
const server = net.createServer();
42+
server.once("error", reject);
43+
server.listen(0, "127.0.0.1", () => {
44+
const address = server.address();
45+
server.close(() => resolve(address.port));
46+
});
47+
});
48+
49+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
50+
51+
const postJSON = async (baseURL, endpoint, body = {}) => {
52+
const response = await fetch(`${baseURL}${endpoint}`, {
53+
method: "POST",
54+
headers: {"Content-Type": "application/json"},
55+
body: JSON.stringify(body),
56+
});
57+
if (!response.ok) {
58+
fail(`${endpoint} returned HTTP ${response.status}`);
59+
}
60+
const data = await response.json();
61+
if (data.code !== 0) {
62+
fail(`${endpoint} failed: ${data.msg || JSON.stringify(data)}`);
63+
}
64+
return data.data;
65+
};
66+
67+
const waitForBoot = async (baseURL) => {
68+
let lastError = "";
69+
for (let i = 0; i < 120; i++) {
70+
try {
71+
const version = await postJSON(baseURL, "/api/system/version");
72+
const progress = await postJSON(baseURL, "/api/system/bootProgress");
73+
if (version === expectedKernelVersion && progress?.progress >= 100) {
74+
return;
75+
}
76+
lastError = `version=${version} progress=${progress?.progress}`;
77+
} catch (error) {
78+
lastError = error.message;
79+
}
80+
await sleep(500);
81+
}
82+
fail(`kernel did not finish booting: ${lastError}`);
83+
};
84+
85+
const main = async () => {
86+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "siyuan-dailynote-db-smoke-workspace-"));
87+
const port = await getFreePort();
88+
const baseURL = `http://127.0.0.1:${port}`;
89+
let kernel;
90+
91+
try {
92+
kernel = spawn(kernelBinary, [
93+
"serve",
94+
"--port", String(port),
95+
"--wd", appDir,
96+
"--workspace", workspace,
97+
"--mode", "dev",
98+
"--lang", "zh-TW",
99+
], {
100+
cwd: root,
101+
stdio: ["ignore", "pipe", "pipe"],
102+
});
103+
kernel.stdout.on("data", (chunk) => process.stdout.write(chunk));
104+
kernel.stderr.on("data", (chunk) => process.stderr.write(chunk));
105+
106+
await waitForBoot(baseURL);
107+
108+
// Notebook A: target for daily notes
109+
const notebookAData = await postJSON(baseURL, "/api/notebook/createNotebook", {name: `Dailynote DB Smoke ${Date.now()}`});
110+
const notebookA = notebookAData.notebook.id || notebookAData.notebook.ID;
111+
if (!notebookA) {
112+
fail(`createNotebook returned no notebook ID: ${JSON.stringify(notebookAData)}`);
113+
}
114+
115+
// Doc holding the database block
116+
const docID = nodeID();
117+
const docData = await postJSON(baseURL, "/api/filetree/createDocWithMd", {
118+
notebook: notebookA,
119+
path: "/Dailynote DB",
120+
markdown: "# Dailynote DB\n",
121+
id: docID,
122+
});
123+
const docIDResult = typeof docData === "string" ? docData : (docData.id || docData.ID);
124+
if (!docIDResult || docIDResult !== docID) {
125+
fail(`createDocWithMd returned unexpected doc id: ${JSON.stringify(docData)}`);
126+
}
127+
128+
// Attribute View block (table layout)
129+
const avID = nodeID();
130+
const avBlockID = nodeID();
131+
await postJSON(baseURL, "/api/block/appendBlock", {
132+
parentID: docID,
133+
dataType: "dom",
134+
data: `<div class="av" data-node-id="${avBlockID}" data-av-id="${avID}" data-type="NodeAttributeView" data-av-type="table"></div>`,
135+
});
136+
137+
// Materialize the AV
138+
const initialView = await postJSON(baseURL, "/api/av/renderAttributeView", {
139+
id: avID,
140+
blockID: avBlockID,
141+
pageSize: -1,
142+
createIfNotExist: true,
143+
});
144+
if (initialView.viewType !== "table") {
145+
fail(`expected table view, got ${initialView.viewType}`);
146+
}
147+
const initialRowCount = initialView.view?.rowCount ?? 0;
148+
if (initialRowCount !== 0) {
149+
fail(`expected 0 rows initially, got ${initialRowCount}`);
150+
}
151+
console.log(`[ok] initial AV rowCount = ${initialRowCount}`);
152+
153+
// Configure notebook A: target database = av block id
154+
const confData = await postJSON(baseURL, "/api/notebook/setNotebookConf", {
155+
notebook: notebookA,
156+
conf: {dailyNoteDatabaseID: avBlockID},
157+
});
158+
if (confData.dailyNoteDatabaseID !== avBlockID) {
159+
fail(`setNotebookConf did not persist dailyNoteDatabaseID: ${JSON.stringify(confData)}`);
160+
}
161+
console.log(`[ok] dailyNoteDatabaseID persisted as ${confData.dailyNoteDatabaseID}`);
162+
163+
// First daily note creation -> should add exactly one row bound to the note
164+
const firstData = await postJSON(baseURL, "/api/filetree/createDailyNote", {notebook: notebookA});
165+
const firstDocID = firstData.id;
166+
if (!firstDocID) {
167+
fail(`createDailyNote returned no doc id: ${JSON.stringify(firstData)}`);
168+
}
169+
console.log(`[ok] first daily note created: ${firstDocID}`);
170+
171+
let view = await postJSON(baseURL, "/api/av/renderAttributeView", {
172+
id: avID,
173+
blockID: avBlockID,
174+
pageSize: -1,
175+
createIfNotExist: false,
176+
});
177+
let rowCount = view.view?.rowCount ?? 0;
178+
if (rowCount !== 1) {
179+
fail(`expected 1 row after first daily note, got ${rowCount}`);
180+
}
181+
let bound = await postJSON(baseURL, "/api/av/getAttributeViewItemIDsByBoundIDs", {
182+
avID,
183+
blockIDs: [firstDocID],
184+
});
185+
if (!bound[firstDocID]) {
186+
fail(`daily note not bound as a row: ${JSON.stringify(bound)}`);
187+
}
188+
const firstItemID = bound[firstDocID];
189+
console.log(`[ok] exactly 1 row after first create, itemID=${firstItemID}`);
190+
191+
// Second daily note creation (same day, existed) -> must NOT add another row
192+
const secondData = await postJSON(baseURL, "/api/filetree/createDailyNote", {notebook: notebookA});
193+
if (secondData.id !== firstDocID) {
194+
fail(`second createDailyNote returned a different doc: ${secondData.id} vs ${firstDocID}`);
195+
}
196+
view = await postJSON(baseURL, "/api/av/renderAttributeView", {
197+
id: avID,
198+
blockID: avBlockID,
199+
pageSize: -1,
200+
createIfNotExist: false,
201+
});
202+
rowCount = view.view?.rowCount ?? 0;
203+
console.log(`[debug] render2 rowCount=${rowCount} rows=${view.view?.rows?.length} viewID=${view.view?.id}`);
204+
console.log(`[debug] render2 view keys: ${Object.keys(view.view || {}).join(",")}`);
205+
const avFile = path.join(workspace, "data", "storage", "av", `${avID}.json`);
206+
if (fs.existsSync(avFile)) {
207+
const avJson = JSON.parse(fs.readFileSync(avFile, "utf8"));
208+
const blockKv = (avJson.keyValues || []).find((kv) => kv.key?.type === "block");
209+
console.log(`[debug] av json block values: ${JSON.stringify((blockKv?.values || []).map((v) => ({id: v.blockID, bound: v.block?.id, detached: v.isDetached})))}`);
210+
console.log(`[debug] av json views itemIds: ${JSON.stringify((avJson.views || []).map((v) => ({id: v.id, itemIds: v.itemIDs}))) }`);
211+
} else {
212+
console.log(`[debug] av file missing: ${avFile}`);
213+
}
214+
if (rowCount !== 1) {
215+
fail(`expected still 1 row after second create, got ${rowCount}`);
216+
}
217+
bound = await postJSON(baseURL, "/api/av/getAttributeViewItemIDsByBoundIDs", {
218+
avID,
219+
blockIDs: [firstDocID],
220+
});
221+
if (bound[firstDocID] !== firstItemID) {
222+
fail(`item id changed on re-create: ${bound[firstDocID]} vs ${firstItemID}`);
223+
}
224+
console.log(`[ok] second create idempotent: still 1 row, same itemID`);
225+
226+
// Notebook B without the setting -> unchanged behaviour
227+
const notebookBData = await postJSON(baseURL, "/api/notebook/createNotebook", {name: `Dailynote No DB ${Date.now()}`});
228+
const notebookB = notebookBData.notebook.id || notebookBData.notebook.ID;
229+
const plainData = await postJSON(baseURL, "/api/filetree/createDailyNote", {notebook: notebookB});
230+
if (!plainData.id) {
231+
fail(`createDailyNote without setting failed: ${JSON.stringify(plainData)}`);
232+
}
233+
console.log(`[ok] unset setting: daily note created normally: ${plainData.id}`);
234+
235+
console.log("SMOKE_TEST_OK: dailyNoteDatabaseID feature verified");
236+
} finally {
237+
if (kernel) {
238+
kernel.kill("SIGTERM");
239+
await sleep(500);
240+
if (kernel.exitCode === null) {
241+
kernel.kill("SIGKILL");
242+
}
243+
}
244+
fs.rmSync(workspace, {recursive: true, force: true});
245+
}
246+
};
247+
248+
main().catch((error) => {
249+
console.error(`SMOKE_TEST_FAILED: ${error.message}`);
250+
process.exit(1);
251+
});

0 commit comments

Comments
 (0)