sqlite-hub 0.16.0 → 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -19
- package/bin/sqlite-hub.js +1041 -224
- package/frontend/index.html +1 -0
- package/frontend/js/api.js +34 -0
- package/frontend/js/app.js +481 -32
- package/frontend/js/components/modal.js +270 -50
- package/frontend/js/components/pageHeader.js +14 -16
- package/frontend/js/components/queryHistoryPanel.js +126 -131
- package/frontend/js/components/queryResults.js +18 -1
- package/frontend/js/components/rowEditorPanel.js +42 -34
- package/frontend/js/components/sidebar.js +1 -0
- package/frontend/js/router.js +8 -0
- package/frontend/js/store.js +655 -2
- package/frontend/js/utils/jsonPreview.js +31 -0
- package/frontend/js/utils/markdownDocuments.js +248 -0
- package/frontend/js/utils/rowEditorValues.js +41 -0
- package/frontend/js/utils/tableScrollState.js +39 -0
- package/frontend/js/views/charts.js +8 -0
- package/frontend/js/views/data.js +4 -1
- package/frontend/js/views/documents.js +300 -0
- package/frontend/js/views/editor.js +2 -0
- package/frontend/js/views/settings.js +39 -3
- package/frontend/styles/components.css +19 -0
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/views.css +422 -0
- package/package.json +2 -1
- package/server/middleware/localRequestSecurity.js +84 -0
- package/server/routes/connections.js +18 -1
- package/server/routes/documents.js +163 -0
- package/server/routes/settings.js +22 -6
- package/server/server.js +18 -1
- package/server/services/nativeFileDialogService.js +168 -0
- package/server/services/sqlite/dataBrowserService.js +0 -4
- package/server/services/sqlite/exportService.js +5 -1
- package/server/services/sqlite/sqlExecutor.js +51 -2
- package/server/services/storage/appStateStore.js +313 -0
- package/server/utils/sqliteTypes.js +17 -8
- package/tests/cli-args.test.js +100 -0
- package/tests/connections-file-dialog-route.test.js +46 -0
- package/tests/copy-column-modal.test.js +97 -0
- package/tests/database-documents.test.js +85 -0
- package/tests/export-blob.test.js +46 -0
- package/tests/json-preview.test.js +49 -0
- package/tests/local-request-security.test.js +85 -0
- package/tests/markdown-documents.test.js +79 -0
- package/tests/native-file-dialog.test.js +78 -0
- package/tests/query-results-truncation.test.js +20 -0
- package/tests/row-editor-null-values.test.js +131 -0
- package/tests/settings-metadata.test.js +16 -0
- package/tests/settings-view.test.js +32 -0
- package/tests/sql-identifier-safety.test.js +9 -3
- package/tests/sql-result-limit.test.js +66 -0
- package/tests/table-scroll-state.test.js +70 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const test = require("node:test");
|
|
6
|
+
|
|
7
|
+
const { AppStateStore } = require("../server/services/storage/appStateStore");
|
|
8
|
+
const { ensureDatabaseDocuments } = require("../server/routes/documents");
|
|
9
|
+
|
|
10
|
+
function createStore(t) {
|
|
11
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-documents-"));
|
|
12
|
+
const store = new AppStateStore(path.join(directory, "state.db"));
|
|
13
|
+
|
|
14
|
+
t.after(() => {
|
|
15
|
+
store.db.close();
|
|
16
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return store;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("database documents are scoped to one database and normalize filenames", (t) => {
|
|
23
|
+
const store = createStore(t);
|
|
24
|
+
const first = store.createDatabaseDocument("db-one", {
|
|
25
|
+
filename: "../Daily Notes",
|
|
26
|
+
content: "# Tasks\n\n- [ ] item1",
|
|
27
|
+
});
|
|
28
|
+
const second = store.createDatabaseDocument("db-one", {
|
|
29
|
+
filename: "Daily Notes.md",
|
|
30
|
+
content: "second",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
assert.equal(first.filename, "Daily Notes.md");
|
|
34
|
+
assert.equal(second.filename, "Daily Notes 2.md");
|
|
35
|
+
assert.equal(store.listDatabaseDocuments("db-one").length, 2);
|
|
36
|
+
assert.equal(store.listDatabaseDocuments("db-two").length, 0);
|
|
37
|
+
assert.throws(() => store.getDatabaseDocument("db-two", first.id), /Document was not found/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("database document updates preserve raw markdown content", (t) => {
|
|
41
|
+
const store = createStore(t);
|
|
42
|
+
const document = store.createDatabaseDocument("db-one", {
|
|
43
|
+
filename: "todo",
|
|
44
|
+
content: "- [ ] item1",
|
|
45
|
+
});
|
|
46
|
+
const updated = store.updateDatabaseDocument("db-one", document.id, {
|
|
47
|
+
filename: "todo-renamed",
|
|
48
|
+
content: "- [x] item1\n1717682400",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
assert.equal(updated.filename, "todo-renamed.md");
|
|
52
|
+
assert.equal(updated.content, "- [x] item1\n1717682400");
|
|
53
|
+
|
|
54
|
+
const deleted = store.deleteDatabaseDocument("db-one", document.id);
|
|
55
|
+
assert.deepEqual(deleted, { id: document.id, deleted: true });
|
|
56
|
+
assert.equal(store.listDatabaseDocuments("db-one").length, 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("document folder creates one initial document for an empty database namespace", (t) => {
|
|
60
|
+
const store = createStore(t);
|
|
61
|
+
const connectionManager = {
|
|
62
|
+
getActiveConnection: () => ({
|
|
63
|
+
id: "db-one",
|
|
64
|
+
label: "trump.sqlite",
|
|
65
|
+
path: "/tmp/trump.sqlite",
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const firstList = ensureDatabaseDocuments({
|
|
70
|
+
appStateStore: store,
|
|
71
|
+
connectionManager,
|
|
72
|
+
databaseKey: "db-one",
|
|
73
|
+
});
|
|
74
|
+
const secondList = ensureDatabaseDocuments({
|
|
75
|
+
appStateStore: store,
|
|
76
|
+
connectionManager,
|
|
77
|
+
databaseKey: "db-one",
|
|
78
|
+
});
|
|
79
|
+
const document = store.getDatabaseDocument("db-one", firstList[0].id);
|
|
80
|
+
|
|
81
|
+
assert.equal(firstList.length, 1);
|
|
82
|
+
assert.equal(secondList.length, 1);
|
|
83
|
+
assert.equal(document.filename, "trump.sqlite.md");
|
|
84
|
+
assert.equal(document.content, "# trump.sqlite\n");
|
|
85
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const Database = require("better-sqlite3");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const { ExportService } = require("../server/services/sqlite/exportService");
|
|
5
|
+
const { SqlExecutor } = require("../server/services/sqlite/sqlExecutor");
|
|
6
|
+
|
|
7
|
+
test("query and table exports include complete BLOB data", () => {
|
|
8
|
+
const db = new Database(":memory:");
|
|
9
|
+
const blob = Buffer.from(Array.from({ length: 100 }, (_, index) => index));
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
db.exec("CREATE TABLE files (id INTEGER PRIMARY KEY, payload BLOB)");
|
|
13
|
+
db.prepare("INSERT INTO files (payload) VALUES (?)").run(blob);
|
|
14
|
+
|
|
15
|
+
const connection = { id: "blob-export-test", label: "Blob export" };
|
|
16
|
+
const connectionManager = {
|
|
17
|
+
getActiveConnection: () => connection,
|
|
18
|
+
getActiveDatabase: () => db,
|
|
19
|
+
};
|
|
20
|
+
const appStateStore = {
|
|
21
|
+
findQueryHistoryItemBySql: () => null,
|
|
22
|
+
getSettings: () => ({ csvDelimiter: "," }),
|
|
23
|
+
recordQueryExecution: () => 1,
|
|
24
|
+
};
|
|
25
|
+
const sqlExecutor = new SqlExecutor({ connectionManager, appStateStore });
|
|
26
|
+
const exportService = new ExportService({
|
|
27
|
+
appStateStore,
|
|
28
|
+
connectionManager,
|
|
29
|
+
sqlExecutor,
|
|
30
|
+
});
|
|
31
|
+
const expectedBase64 = blob.toString("base64");
|
|
32
|
+
const queryExport = exportService.exportQuery(
|
|
33
|
+
"SELECT payload FROM files ORDER BY id",
|
|
34
|
+
{ format: "csv" }
|
|
35
|
+
);
|
|
36
|
+
const tableExport = exportService.exportTable("files", { format: "tsv" });
|
|
37
|
+
|
|
38
|
+
for (const content of [queryExport.content, tableExport.content]) {
|
|
39
|
+
assert.equal(content.includes(expectedBase64), true);
|
|
40
|
+
assert.match(content, /""encoding"":""base64""/);
|
|
41
|
+
assert.doesNotMatch(content, /base64Preview|hexPreview/);
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
db.close();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const { readFileSync } = require("node:fs");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
|
|
6
|
+
let jsonPreviewModulePromise = null;
|
|
7
|
+
|
|
8
|
+
function loadJsonPreviewModule() {
|
|
9
|
+
if (!jsonPreviewModulePromise) {
|
|
10
|
+
const source = readFileSync(
|
|
11
|
+
path.resolve(__dirname, "../frontend/js/utils/jsonPreview.js"),
|
|
12
|
+
"utf8"
|
|
13
|
+
);
|
|
14
|
+
const url = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
|
15
|
+
|
|
16
|
+
jsonPreviewModulePromise = import(url);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return jsonPreviewModulePromise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("JSON preview formats objects and arrays with line breaks and indentation", async () => {
|
|
23
|
+
const { formatJsonPreview } = await loadJsonPreviewModule();
|
|
24
|
+
|
|
25
|
+
assert.equal(
|
|
26
|
+
formatJsonPreview('{"event":"created","metadata":{"source":"api"}}'),
|
|
27
|
+
[
|
|
28
|
+
"{",
|
|
29
|
+
' "event": "created",',
|
|
30
|
+
' "metadata": {',
|
|
31
|
+
' "source": "api"',
|
|
32
|
+
" }",
|
|
33
|
+
"}",
|
|
34
|
+
].join("\n")
|
|
35
|
+
);
|
|
36
|
+
assert.equal(
|
|
37
|
+
formatJsonPreview([1, { active: true }]),
|
|
38
|
+
["[", " 1,", " {", ' "active": true', " }", "]"].join("\n")
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("JSON preview ignores invalid JSON and scalar values", async () => {
|
|
43
|
+
const { formatJsonPreview } = await loadJsonPreviewModule();
|
|
44
|
+
|
|
45
|
+
assert.equal(formatJsonPreview("plain text"), null);
|
|
46
|
+
assert.equal(formatJsonPreview("{invalid"), null);
|
|
47
|
+
assert.equal(formatJsonPreview("42"), null);
|
|
48
|
+
assert.equal(formatJsonPreview(null), null);
|
|
49
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
const {
|
|
4
|
+
LOOPBACK_HOST,
|
|
5
|
+
listenOnLoopback,
|
|
6
|
+
localRequestSecurity,
|
|
7
|
+
} = require("../server/middleware/localRequestSecurity");
|
|
8
|
+
|
|
9
|
+
function runMiddleware({ method = "GET", protocol = "http", headers = {} } = {}) {
|
|
10
|
+
const normalizedHeaders = Object.fromEntries(
|
|
11
|
+
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value])
|
|
12
|
+
);
|
|
13
|
+
const req = {
|
|
14
|
+
method,
|
|
15
|
+
protocol,
|
|
16
|
+
get(name) {
|
|
17
|
+
return normalizedHeaders[String(name).toLowerCase()];
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
localRequestSecurity(req, {}, (error) => resolve(error ?? null));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("server listener binds explicitly to the IPv4 loopback address", () => {
|
|
27
|
+
let receivedArguments = null;
|
|
28
|
+
const listener = {};
|
|
29
|
+
const app = {
|
|
30
|
+
listen(...args) {
|
|
31
|
+
receivedArguments = args;
|
|
32
|
+
return listener;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
assert.equal(listenOnLoopback(app, 4173), listener);
|
|
37
|
+
assert.deepEqual(receivedArguments, [4173, LOOPBACK_HOST]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("local API security accepts same-origin browser and CLI requests", async () => {
|
|
41
|
+
assert.equal(
|
|
42
|
+
await runMiddleware({
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
host: "127.0.0.1:4173",
|
|
46
|
+
origin: "http://127.0.0.1:4173",
|
|
47
|
+
"sec-fetch-site": "same-origin",
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
null
|
|
51
|
+
);
|
|
52
|
+
assert.equal(
|
|
53
|
+
await runMiddleware({
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { host: "localhost:4173" },
|
|
56
|
+
}),
|
|
57
|
+
null
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("local API security rejects foreign hosts and cross-origin mutations", async () => {
|
|
62
|
+
const foreignHostError = await runMiddleware({
|
|
63
|
+
headers: { host: "example.test:4173" },
|
|
64
|
+
});
|
|
65
|
+
const crossOriginError = await runMiddleware({
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
host: "127.0.0.1:4173",
|
|
69
|
+
origin: "https://example.test",
|
|
70
|
+
"sec-fetch-site": "cross-site",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
const wrongSchemeError = await runMiddleware({
|
|
74
|
+
method: "PATCH",
|
|
75
|
+
headers: {
|
|
76
|
+
host: "localhost:4173",
|
|
77
|
+
origin: "https://localhost:4173",
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
for (const error of [foreignHostError, crossOriginError, wrongSchemeError]) {
|
|
82
|
+
assert.equal(error?.statusCode, 403);
|
|
83
|
+
assert.equal(error?.code, "LOCAL_REQUEST_REQUIRED");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { pathToFileURL } = require("node:url");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
|
|
6
|
+
let markdownModulePromise = null;
|
|
7
|
+
|
|
8
|
+
function loadMarkdownModule() {
|
|
9
|
+
if (!markdownModulePromise) {
|
|
10
|
+
markdownModulePromise = import(
|
|
11
|
+
pathToFileURL(path.resolve(__dirname, "../frontend/js/utils/markdownDocuments.js")).href
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return markdownModulePromise;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("markdown preview renders clickable todo checkboxes with source line indexes", async () => {
|
|
19
|
+
const { renderMarkdownPreview } = await loadMarkdownModule();
|
|
20
|
+
const html = renderMarkdownPreview(["# Tasks", "- [ ] item1", "- [x] item2", "```", "- [ ] code", "```"].join("\n"));
|
|
21
|
+
|
|
22
|
+
assert.match(html, /document-markdown-task-list/);
|
|
23
|
+
assert.match(html, /data-action="toggle-document-todo"/);
|
|
24
|
+
assert.match(html, /data-line-index="1"/);
|
|
25
|
+
assert.match(html, /data-line-index="2"/);
|
|
26
|
+
assert.doesNotMatch(html, /data-line-index="4"/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("markdown todo toggle updates only real task lines", async () => {
|
|
30
|
+
const { toggleMarkdownTodoLine } = await loadMarkdownModule();
|
|
31
|
+
const markdown = ["- [ ] item1", "```", "- [ ] code", "```", "- [x] item2"].join("\n");
|
|
32
|
+
|
|
33
|
+
assert.equal(toggleMarkdownTodoLine(markdown, 0).split("\n")[0], "- [x] item1");
|
|
34
|
+
assert.equal(toggleMarkdownTodoLine(markdown, 2), markdown);
|
|
35
|
+
assert.equal(toggleMarkdownTodoLine(markdown, 4).split("\n")[4], "- [ ] item2");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("markdown preview fallback renders unordered and ordered lists", async () => {
|
|
39
|
+
const { renderMarkdownPreview } = await loadMarkdownModule();
|
|
40
|
+
const previousMarked = globalThis.marked;
|
|
41
|
+
|
|
42
|
+
globalThis.marked = undefined;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const html = renderMarkdownPreview(["- alpha", "- beta", "", "1. first", "2. second"].join("\n"));
|
|
46
|
+
|
|
47
|
+
assert.match(html, /<ul><li>alpha<\/li><li>beta<\/li><\/ul>/);
|
|
48
|
+
assert.match(html, /<ol><li>first<\/li><li>second<\/li><\/ol>/);
|
|
49
|
+
} finally {
|
|
50
|
+
globalThis.marked = previousMarked;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("markdown preview escapes raw html content", async () => {
|
|
55
|
+
const { renderMarkdownPreview } = await loadMarkdownModule();
|
|
56
|
+
const html = renderMarkdownPreview('<img src=x onerror="alert(1)">\n- [ ] <script>alert(1)</script>');
|
|
57
|
+
|
|
58
|
+
assert.doesNotMatch(html, /<script>/i);
|
|
59
|
+
assert.doesNotMatch(html, /<img/i);
|
|
60
|
+
assert.match(html, /<script>/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("markdown preview strips executable link targets from rendered markdown", async () => {
|
|
64
|
+
const { renderMarkdownPreview } = await loadMarkdownModule();
|
|
65
|
+
const previousMarked = globalThis.marked;
|
|
66
|
+
|
|
67
|
+
globalThis.marked = {
|
|
68
|
+
parse: () => '<p><a href="javascript:alert(1)">unsafe</a></p>',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const html = renderMarkdownPreview("[unsafe](javascript:alert(1))");
|
|
73
|
+
|
|
74
|
+
assert.doesNotMatch(html, /href="javascript:/i);
|
|
75
|
+
assert.match(html, /href="#"/);
|
|
76
|
+
} finally {
|
|
77
|
+
globalThis.marked = previousMarked;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
const {
|
|
4
|
+
NativeFileDialogService,
|
|
5
|
+
buildDialogAttempts,
|
|
6
|
+
normalizeSelectedDatabasePath,
|
|
7
|
+
} = require("../server/services/nativeFileDialogService");
|
|
8
|
+
|
|
9
|
+
test("native database dialog normalizes paths and adds a default extension", () => {
|
|
10
|
+
assert.equal(normalizeSelectedDatabasePath("/tmp/customer-data"), "/tmp/customer-data.sqlite");
|
|
11
|
+
assert.equal(normalizeSelectedDatabasePath("/tmp/customer-data.db\n"), "/tmp/customer-data.db");
|
|
12
|
+
assert.equal(normalizeSelectedDatabasePath(""), null);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("native database dialog builds platform-specific save commands", () => {
|
|
16
|
+
const macAttempt = buildDialogAttempts({ platform: "darwin", homeDirectory: "/Users/test" })[0];
|
|
17
|
+
const windowsAttempt = buildDialogAttempts({ platform: "win32", homeDirectory: "C:\\Users\\test" })[0];
|
|
18
|
+
const linuxAttempts = buildDialogAttempts({ platform: "linux", homeDirectory: "/home/test" });
|
|
19
|
+
|
|
20
|
+
assert.equal(macAttempt.command, "osascript");
|
|
21
|
+
assert.match(macAttempt.args.join(" "), /choose file name/);
|
|
22
|
+
assert.equal(windowsAttempt.command, "powershell.exe");
|
|
23
|
+
assert.match(windowsAttempt.args.at(-1), /SaveFileDialog/);
|
|
24
|
+
assert.deepEqual(linuxAttempts.map((attempt) => attempt.command), ["zenity", "kdialog"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("native database dialog returns null when the user cancels", async () => {
|
|
28
|
+
const service = new NativeFileDialogService({
|
|
29
|
+
platform: "darwin",
|
|
30
|
+
homeDirectory: "/Users/test",
|
|
31
|
+
executeFile: async () => {
|
|
32
|
+
const error = new Error("User canceled.");
|
|
33
|
+
error.code = 1;
|
|
34
|
+
throw error;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
assert.equal(await service.chooseCreateDatabasePath(), null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("native database dialog does not hide macOS script failures as cancellations", async () => {
|
|
42
|
+
const service = new NativeFileDialogService({
|
|
43
|
+
platform: "darwin",
|
|
44
|
+
homeDirectory: "/Users/test",
|
|
45
|
+
executeFile: async () => {
|
|
46
|
+
const error = new Error("AppleScript syntax error");
|
|
47
|
+
error.code = 1;
|
|
48
|
+
throw error;
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await assert.rejects(service.chooseCreateDatabasePath(), (error) => {
|
|
53
|
+
assert.equal(error.code, "NATIVE_FILE_DIALOG_FAILED");
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("native database dialog falls back from zenity to kdialog", async () => {
|
|
59
|
+
const commands = [];
|
|
60
|
+
const service = new NativeFileDialogService({
|
|
61
|
+
platform: "linux",
|
|
62
|
+
homeDirectory: "/home/test",
|
|
63
|
+
executeFile: async (command) => {
|
|
64
|
+
commands.push(command);
|
|
65
|
+
|
|
66
|
+
if (command === "zenity") {
|
|
67
|
+
const error = new Error("not found");
|
|
68
|
+
error.code = "ENOENT";
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { stdout: "/home/test/catalog.sqlite3\n" };
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.equal(await service.chooseCreateDatabasePath(), "/home/test/catalog.sqlite3");
|
|
77
|
+
assert.deepEqual(commands, ["zenity", "kdialog"]);
|
|
78
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const { pathToFileURL } = require("node:url");
|
|
5
|
+
|
|
6
|
+
test("query results render an explicit truncation notice", async () => {
|
|
7
|
+
const moduleUrl = pathToFileURL(
|
|
8
|
+
path.resolve(__dirname, "../frontend/js/components/queryResults.js")
|
|
9
|
+
).href;
|
|
10
|
+
const { renderQueryResultsPane } = await import(moduleUrl);
|
|
11
|
+
const html = renderQueryResultsPane({
|
|
12
|
+
columns: ["id"],
|
|
13
|
+
rows: [{ id: 1 }, { id: 2 }],
|
|
14
|
+
truncated: true,
|
|
15
|
+
rowLimit: 2,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
assert.match(html, /Showing the first 2 rows/);
|
|
19
|
+
assert.match(html, /export it to process the complete result set/);
|
|
20
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const Database = require("better-sqlite3");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
const { pathToFileURL } = require("node:url");
|
|
6
|
+
const { DataBrowserService } = require("../server/services/sqlite/dataBrowserService");
|
|
7
|
+
|
|
8
|
+
async function loadFrontendModule(relativePath) {
|
|
9
|
+
return import(pathToFileURL(path.resolve(__dirname, relativePath)).href);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test("row editor values preserve NULL and empty string as distinct values", async () => {
|
|
13
|
+
const {
|
|
14
|
+
buildRowEditorSubmittedValues,
|
|
15
|
+
getRowEditorValueState,
|
|
16
|
+
getRowEditorValueStateLabel,
|
|
17
|
+
} = await loadFrontendModule("../frontend/js/utils/rowEditorValues.js");
|
|
18
|
+
const formData = new FormData();
|
|
19
|
+
|
|
20
|
+
formData.append("field:title", "");
|
|
21
|
+
formData.append("field:description", "");
|
|
22
|
+
formData.append("field:count", "42");
|
|
23
|
+
|
|
24
|
+
assert.deepEqual(buildRowEditorSubmittedValues(formData, {
|
|
25
|
+
title: { initialState: "empty", dirty: false },
|
|
26
|
+
description: { initialState: "null", dirty: false },
|
|
27
|
+
}), {
|
|
28
|
+
title: "",
|
|
29
|
+
description: null,
|
|
30
|
+
count: "42",
|
|
31
|
+
});
|
|
32
|
+
assert.equal(
|
|
33
|
+
buildRowEditorSubmittedValues(formData, {
|
|
34
|
+
description: { initialState: "null", dirty: true },
|
|
35
|
+
}).description,
|
|
36
|
+
""
|
|
37
|
+
);
|
|
38
|
+
assert.equal(getRowEditorValueState(null), "null");
|
|
39
|
+
assert.equal(getRowEditorValueState(""), "empty");
|
|
40
|
+
assert.equal(getRowEditorValueState("text"), "value");
|
|
41
|
+
assert.equal(getRowEditorValueStateLabel("null"), "NULL");
|
|
42
|
+
assert.equal(getRowEditorValueStateLabel("empty"), "EMPTY STRING");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("row editor renders visible NULL and empty-string states", async () => {
|
|
46
|
+
const { renderRowEditorPanel } = await loadFrontendModule(
|
|
47
|
+
"../frontend/js/components/rowEditorPanel.js"
|
|
48
|
+
);
|
|
49
|
+
const html = renderRowEditorPanel({
|
|
50
|
+
title: "Values",
|
|
51
|
+
closeAction: "close",
|
|
52
|
+
formName: "save-data-row",
|
|
53
|
+
editableFields: [
|
|
54
|
+
{
|
|
55
|
+
name: "nullable_text",
|
|
56
|
+
label: "nullable_text",
|
|
57
|
+
rawValue: null,
|
|
58
|
+
value: "",
|
|
59
|
+
notNull: false,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "empty_text",
|
|
63
|
+
label: "empty_text",
|
|
64
|
+
rawValue: "",
|
|
65
|
+
value: "",
|
|
66
|
+
notNull: false,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "required_text",
|
|
70
|
+
label: "required_text",
|
|
71
|
+
rawValue: "value",
|
|
72
|
+
value: "value",
|
|
73
|
+
notNull: true,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
assert.match(html, /data-value-state="null"[^>]*>NULL</s);
|
|
79
|
+
assert.match(html, /data-value-state="empty"[^>]*>EMPTY STRING</s);
|
|
80
|
+
assert.match(html, /data-value-state="value"[^>]*>VALUE</s);
|
|
81
|
+
assert.doesNotMatch(html, /field-null:|row-editor-null-toggle|Set NULL/);
|
|
82
|
+
assert.doesNotMatch(html, /name="field:nullable_text"[\s\S]*?disabled/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("data updates can change NULL to empty string and empty string to NULL", () => {
|
|
86
|
+
const db = new Database(":memory:");
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
db.exec(`
|
|
90
|
+
CREATE TABLE values_test (
|
|
91
|
+
id INTEGER PRIMARY KEY,
|
|
92
|
+
nullable_text TEXT,
|
|
93
|
+
empty_text TEXT
|
|
94
|
+
);
|
|
95
|
+
INSERT INTO values_test (id, nullable_text, empty_text) VALUES (1, NULL, '');
|
|
96
|
+
`);
|
|
97
|
+
const service = new DataBrowserService({
|
|
98
|
+
connectionManager: {
|
|
99
|
+
assertWritable() {},
|
|
100
|
+
getActiveDatabase: () => db,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
const identity = service.getTableData("values_test", { limit: 10 }).rows[0].__identity;
|
|
104
|
+
const preview = service.previewTableRowUpdate("values_test", {
|
|
105
|
+
identity,
|
|
106
|
+
values: {
|
|
107
|
+
nullable_text: "",
|
|
108
|
+
empty_text: null,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
assert.deepEqual(preview.changes, [
|
|
113
|
+
{ column: "nullable_text", oldValue: "NULL", newValue: "" },
|
|
114
|
+
{ column: "empty_text", oldValue: "", newValue: "NULL" },
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
service.updateTableRow("values_test", {
|
|
118
|
+
identity,
|
|
119
|
+
values: {
|
|
120
|
+
nullable_text: "",
|
|
121
|
+
empty_text: null,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
const row = db.prepare("SELECT nullable_text, empty_text FROM values_test WHERE id = 1").get();
|
|
125
|
+
|
|
126
|
+
assert.equal(row.nullable_text, "");
|
|
127
|
+
assert.equal(row.empty_text, null);
|
|
128
|
+
} finally {
|
|
129
|
+
db.close();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
|
|
4
|
+
const { version } = require("../package.json");
|
|
5
|
+
const { readSettingsMetadata, readSqliteVersion } = require("../server/routes/settings");
|
|
6
|
+
|
|
7
|
+
test("settings metadata exposes app and SQLite versions", () => {
|
|
8
|
+
const metadata = readSettingsMetadata();
|
|
9
|
+
|
|
10
|
+
assert.equal(metadata.appVersion, version);
|
|
11
|
+
assert.match(metadata.sqliteVersion, /^\d+\.\d+\.\d+$/);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("SQLite runtime version is readable", () => {
|
|
15
|
+
assert.match(readSqliteVersion(), /^\d+\.\d+\.\d+$/);
|
|
16
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const { pathToFileURL } = require("node:url");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
|
|
6
|
+
let settingsViewModulePromise = null;
|
|
7
|
+
|
|
8
|
+
function loadSettingsViewModule() {
|
|
9
|
+
if (!settingsViewModulePromise) {
|
|
10
|
+
settingsViewModulePromise = import(
|
|
11
|
+
pathToFileURL(path.resolve(__dirname, "../frontend/js/views/settings.js")).href
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return settingsViewModulePromise;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("settings view shows SQLite runtime version and custom port command", async () => {
|
|
19
|
+
const { renderSettingsView } = await loadSettingsViewModule();
|
|
20
|
+
const rendered = renderSettingsView({
|
|
21
|
+
settings: {
|
|
22
|
+
loading: false,
|
|
23
|
+
error: null,
|
|
24
|
+
appVersion: "1.2.3",
|
|
25
|
+
sqliteVersion: "3.53.1",
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
assert.match(rendered.main, /SQLite_Runtime/);
|
|
30
|
+
assert.match(rendered.main, /3\.53\.1/);
|
|
31
|
+
assert.match(rendered.main, /sqlite-hub --port:PORT/);
|
|
32
|
+
});
|
|
@@ -114,7 +114,8 @@ test("data browser mutations preserve quoted dynamic identifiers", () => {
|
|
|
114
114
|
"UPDATE",
|
|
115
115
|
quoteIdentifier(tableName),
|
|
116
116
|
"SET",
|
|
117
|
-
quoteIdentifier(valueColumn) + " =
|
|
117
|
+
quoteIdentifier(valueColumn) + " = ?,",
|
|
118
|
+
quoteIdentifier(noteColumn) + " = ?",
|
|
118
119
|
"WHERE",
|
|
119
120
|
"rowid IS ?",
|
|
120
121
|
].join(" ")
|
|
@@ -125,6 +126,11 @@ test("data browser mutations preserve quoted dynamic identifiers", () => {
|
|
|
125
126
|
oldValue: "before",
|
|
126
127
|
newValue: "after",
|
|
127
128
|
},
|
|
129
|
+
{
|
|
130
|
+
column: noteColumn,
|
|
131
|
+
oldValue: "NULL",
|
|
132
|
+
newValue: "",
|
|
133
|
+
},
|
|
128
134
|
]);
|
|
129
135
|
|
|
130
136
|
const updated = service.updateTableRow(tableName, {
|
|
@@ -136,7 +142,7 @@ test("data browser mutations preserve quoted dynamic identifiers", () => {
|
|
|
136
142
|
});
|
|
137
143
|
|
|
138
144
|
assert.equal(updated.row[valueColumn], "after");
|
|
139
|
-
assert.equal(updated.row[noteColumn],
|
|
145
|
+
assert.equal(updated.row[noteColumn], "");
|
|
140
146
|
const persistedRow = db.prepare(
|
|
141
147
|
[
|
|
142
148
|
"SELECT",
|
|
@@ -148,7 +154,7 @@ test("data browser mutations preserve quoted dynamic identifiers", () => {
|
|
|
148
154
|
).get();
|
|
149
155
|
|
|
150
156
|
assert.equal(persistedRow[valueColumn], "after");
|
|
151
|
-
assert.equal(persistedRow[noteColumn],
|
|
157
|
+
assert.equal(persistedRow[noteColumn], "");
|
|
152
158
|
|
|
153
159
|
const deleted = service.deleteTableRow(tableName, {
|
|
154
160
|
identity: updated.row.__identity,
|