enigmatic 0.34.0 → 0.35.0
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 +61 -95
- package/bin/enigmatic.js +3 -0
- package/client/public/AGENTS.md +314 -0
- package/{public → client/public}/client.js +13 -6
- package/client/public/index.html +197 -0
- package/clientserver.png +0 -0
- package/package.json +7 -9
- package/server/bun-server.js +119 -0
- package/__tests__/e2.test.js +0 -340
- package/__tests__/jest.config.js +0 -7
- package/__tests__/jest.setup.js +0 -9
- package/beemap.js +0 -47
- package/bun-server.js +0 -122
- package/public/index.html +0 -48
- package/public/index2.html +0 -10
- /package/{public → client/public}/custom.js +0 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Enigmatic Webapp</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #f1f5f9;
|
|
10
|
+
--surface: #ffffff;
|
|
11
|
+
--text: #0f172a;
|
|
12
|
+
--text-muted: #64748b;
|
|
13
|
+
--accent: #6366f1;
|
|
14
|
+
--accent-hover: #4f46e5;
|
|
15
|
+
--border: #e2e8f0;
|
|
16
|
+
--input-bg: #f8fafc;
|
|
17
|
+
--radius: 12px;
|
|
18
|
+
--radius-sm: 8px;
|
|
19
|
+
--shadow: 0 1px 3px rgba(0,0,0,.06);
|
|
20
|
+
--shadow-md: 0 4px 12px rgba(0,0,0,.06);
|
|
21
|
+
}
|
|
22
|
+
* { box-sizing: border-box; }
|
|
23
|
+
body {
|
|
24
|
+
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
|
25
|
+
font-size: 15px;
|
|
26
|
+
line-height: 1.6;
|
|
27
|
+
max-width: 600px;
|
|
28
|
+
margin: 0 auto;
|
|
29
|
+
padding: 32px 24px;
|
|
30
|
+
background: var(--bg);
|
|
31
|
+
color: var(--text);
|
|
32
|
+
-webkit-font-smoothing: antialiased;
|
|
33
|
+
}
|
|
34
|
+
h1 {
|
|
35
|
+
font-size: 1.75rem;
|
|
36
|
+
font-weight: 700;
|
|
37
|
+
letter-spacing: -0.02em;
|
|
38
|
+
margin: 0 0 28px;
|
|
39
|
+
color: var(--text);
|
|
40
|
+
}
|
|
41
|
+
section {
|
|
42
|
+
background: var(--surface);
|
|
43
|
+
border-radius: var(--radius);
|
|
44
|
+
padding: 20px;
|
|
45
|
+
margin-bottom: 20px;
|
|
46
|
+
box-shadow: var(--shadow);
|
|
47
|
+
border: 1px solid var(--border);
|
|
48
|
+
}
|
|
49
|
+
section h2 {
|
|
50
|
+
font-size: 0.8125rem;
|
|
51
|
+
font-weight: 600;
|
|
52
|
+
letter-spacing: 0.02em;
|
|
53
|
+
text-transform: uppercase;
|
|
54
|
+
color: var(--text-muted);
|
|
55
|
+
margin: 0 0 14px;
|
|
56
|
+
}
|
|
57
|
+
section p { margin: 0 0 12px; color: var(--text-muted); font-size: 0.9375rem; }
|
|
58
|
+
input {
|
|
59
|
+
font-size: 14px;
|
|
60
|
+
padding: 10px 14px;
|
|
61
|
+
border-radius: var(--radius-sm);
|
|
62
|
+
border: 1px solid var(--border);
|
|
63
|
+
background: var(--input-bg);
|
|
64
|
+
color: var(--text);
|
|
65
|
+
width: 100%;
|
|
66
|
+
max-width: 200px;
|
|
67
|
+
transition: border-color .15s, box-shadow .15s;
|
|
68
|
+
}
|
|
69
|
+
input:focus {
|
|
70
|
+
outline: none;
|
|
71
|
+
border-color: var(--accent);
|
|
72
|
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, .15);
|
|
73
|
+
}
|
|
74
|
+
input::placeholder { color: var(--text-muted); opacity: .8; }
|
|
75
|
+
button {
|
|
76
|
+
font-size: 14px;
|
|
77
|
+
font-weight: 500;
|
|
78
|
+
padding: 10px 16px;
|
|
79
|
+
border-radius: var(--radius-sm);
|
|
80
|
+
border: none;
|
|
81
|
+
cursor: pointer;
|
|
82
|
+
transition: background .15s, transform .05s;
|
|
83
|
+
}
|
|
84
|
+
button:active { transform: scale(0.98); }
|
|
85
|
+
button:not(.secondary) {
|
|
86
|
+
background: var(--accent);
|
|
87
|
+
color: #fff;
|
|
88
|
+
margin-right: 8px;
|
|
89
|
+
}
|
|
90
|
+
button:not(.secondary):hover { background: var(--accent-hover); }
|
|
91
|
+
button.secondary {
|
|
92
|
+
background: var(--input-bg);
|
|
93
|
+
color: var(--text);
|
|
94
|
+
border: 1px solid var(--border);
|
|
95
|
+
}
|
|
96
|
+
button.secondary:hover { background: var(--border); }
|
|
97
|
+
#auth-status { margin-right: 12px; color: var(--text-muted); font-size: 14px; }
|
|
98
|
+
.row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
|
|
99
|
+
.row:last-of-type { margin-bottom: 0; }
|
|
100
|
+
#kv-result {
|
|
101
|
+
margin-top: 12px;
|
|
102
|
+
padding: 12px 14px;
|
|
103
|
+
background: var(--input-bg);
|
|
104
|
+
border-radius: var(--radius-sm);
|
|
105
|
+
font-size: 13px;
|
|
106
|
+
font-family: ui-monospace, monospace;
|
|
107
|
+
white-space: pre-wrap;
|
|
108
|
+
word-break: break-all;
|
|
109
|
+
min-height: 24px;
|
|
110
|
+
border: 1px solid var(--border);
|
|
111
|
+
}
|
|
112
|
+
.error { color: #dc2626; }
|
|
113
|
+
.reactive-split { display: flex; gap: 20px; align-items: flex-start; flex-wrap: wrap; }
|
|
114
|
+
.reactive-demo { flex: 1; min-width: 180px; }
|
|
115
|
+
.reactive-snippet {
|
|
116
|
+
flex: 1;
|
|
117
|
+
min-width: 220px;
|
|
118
|
+
font-size: 12px;
|
|
119
|
+
background: #1e293b;
|
|
120
|
+
color: #e2e8f0;
|
|
121
|
+
border-radius: var(--radius-sm);
|
|
122
|
+
padding: 14px;
|
|
123
|
+
overflow-x: auto;
|
|
124
|
+
border: none;
|
|
125
|
+
}
|
|
126
|
+
.reactive-snippet pre { margin: 0; font-family: ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
|
|
127
|
+
</style>
|
|
128
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
129
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
130
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
131
|
+
</head>
|
|
132
|
+
<body>
|
|
133
|
+
<h1>Enigmatic Webapp</h1>
|
|
134
|
+
|
|
135
|
+
<section>
|
|
136
|
+
<h2>Auth</h2>
|
|
137
|
+
<div class="row">
|
|
138
|
+
<span id="auth-status">Checking…</span>
|
|
139
|
+
<button onclick="window.login()">Login</button>
|
|
140
|
+
<button class="secondary" onclick="window.logout()">Logout</button>
|
|
141
|
+
</div>
|
|
142
|
+
</section>
|
|
143
|
+
|
|
144
|
+
<section>
|
|
145
|
+
<h2>Reactive state</h2>
|
|
146
|
+
<p>Edit the message; the custom element updates automatically.</p>
|
|
147
|
+
<div class="reactive-split">
|
|
148
|
+
<div class="reactive-demo">
|
|
149
|
+
<div class="row">
|
|
150
|
+
<input type="text" id="msg-input" placeholder="Message" value="World">
|
|
151
|
+
<button onclick="window.state.message = window.$('#msg-input').value">Update</button>
|
|
152
|
+
</div>
|
|
153
|
+
<p style="margin: 12px 0 0; font-size: 14px;"><hello-world data="message"></hello-world></p>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="reactive-snippet">
|
|
156
|
+
<pre><!-- HTML: data="message" binds to state.message -->
|
|
157
|
+
<hello-world data="message"></hello-world>
|
|
158
|
+
|
|
159
|
+
// JS: updating state re-renders the element
|
|
160
|
+
window.state.message = "World";</pre>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</section>
|
|
164
|
+
|
|
165
|
+
<section>
|
|
166
|
+
<h2>KV storage</h2>
|
|
167
|
+
<div class="row">
|
|
168
|
+
<input type="text" id="kv-key" placeholder="Key" value="greeting">
|
|
169
|
+
<input type="text" id="kv-value" placeholder="Value" value="Hello from KV">
|
|
170
|
+
</div>
|
|
171
|
+
<div class="row">
|
|
172
|
+
<button onclick="window.get(window.$('#kv-key').value).then(r => { window.$('#kv-result').textContent = JSON.stringify(r); window.$('#kv-result').classList.remove('error'); }).catch(e => { window.$('#kv-result').textContent = e.message; window.$('#kv-result').classList.add('error'); })">Get</button>
|
|
173
|
+
<button onclick="window.set(window.$('#kv-key').value, window.$('#kv-value').value).then(r => { window.$('#kv-result').textContent = JSON.stringify(r); window.$('#kv-result').classList.remove('error'); }).catch(e => { window.$('#kv-result').textContent = e.message; window.$('#kv-result').classList.add('error'); })">Set</button>
|
|
174
|
+
<button onclick="window.delete(window.$('#kv-key').value).then(r => { window.$('#kv-result').textContent = JSON.stringify(r); window.$('#kv-result').classList.remove('error'); }).catch(e => { window.$('#kv-result').textContent = e.message; window.$('#kv-result').classList.add('error'); })">Delete</button>
|
|
175
|
+
</div>
|
|
176
|
+
<div id="kv-result"></div>
|
|
177
|
+
</section>
|
|
178
|
+
|
|
179
|
+
<section>
|
|
180
|
+
<h2>Files</h2>
|
|
181
|
+
<file-widget></file-widget>
|
|
182
|
+
</section>
|
|
183
|
+
|
|
184
|
+
<script src="client.js"></script>
|
|
185
|
+
<script src="custom.js"></script>
|
|
186
|
+
<script>
|
|
187
|
+
window.api_url = window.api_url || window.location.origin;
|
|
188
|
+
window.state.message = window.$('#msg-input').value;
|
|
189
|
+
|
|
190
|
+
window.me().then(function(u) {
|
|
191
|
+
window.$('#auth-status').textContent = u ? ('Logged in as ' + (u.email || u.sub)) : 'Not logged in';
|
|
192
|
+
}).catch(function() {
|
|
193
|
+
window.$('#auth-status').textContent = 'Not logged in';
|
|
194
|
+
});
|
|
195
|
+
</script>
|
|
196
|
+
</body>
|
|
197
|
+
</html>
|
package/clientserver.png
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "enigmatic",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
"start": "bun --hot ./bun-server.js",
|
|
7
|
-
"test": "jest --config __tests__/jest.config.js"
|
|
3
|
+
"version": "0.35.0",
|
|
4
|
+
"bin": {
|
|
5
|
+
"enigmatic": "./bin/enigmatic.js"
|
|
8
6
|
},
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
"
|
|
7
|
+
"unpkg": "./client/public/client.js",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "bun run bin/enigmatic.js",
|
|
10
|
+
"hot": "bun --hot bin/enigmatic.js"
|
|
13
11
|
}
|
|
14
12
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { S3Client } from "bun";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { appendFile, mkdir, readFile } from "fs/promises";
|
|
4
|
+
|
|
5
|
+
const dir = import.meta.dir;
|
|
6
|
+
const certsDir = join(dir, "certs");
|
|
7
|
+
const publicDir = join(dir, "..", "client", "public");
|
|
8
|
+
const kvDir = join(dir, "kv");
|
|
9
|
+
const sessions = new Map();
|
|
10
|
+
const userKv = {};
|
|
11
|
+
|
|
12
|
+
const kvPath = (sub) => join(kvDir, `${String(sub).replace(/[^a-zA-Z0-9_-]/g, "_")}.jsonl`);
|
|
13
|
+
const json = (d, s = 200, h = {}) => new Response(JSON.stringify(d), { status: s, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PURGE, PROPFIND, PATCH, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization, Cookie", "Access-Control-Allow-Credentials": "true", "Content-Type": "application/json", ...h } });
|
|
14
|
+
const redir = (url, cookie) => new Response(null, { status: 302, headers: { Location: url, ...(cookie && { "Set-Cookie": cookie }) } });
|
|
15
|
+
|
|
16
|
+
async function getUserMap(sub) {
|
|
17
|
+
if (userKv[sub]) return userKv[sub];
|
|
18
|
+
const m = new Map();
|
|
19
|
+
try {
|
|
20
|
+
let buf = await readFile(kvPath(sub), "utf8");
|
|
21
|
+
if (buf.charCodeAt(0) === 0xfeff) buf = buf.slice(1);
|
|
22
|
+
const lines = buf.trim().split("\n").filter(Boolean);
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
try {
|
|
25
|
+
const row = JSON.parse(line);
|
|
26
|
+
if (Array.isArray(row) && row.length >= 2) {
|
|
27
|
+
m.set(row[0], row[1]);
|
|
28
|
+
} else if (row?.action === "update" && row.key !== undefined) {
|
|
29
|
+
m.set(row.key, row.value);
|
|
30
|
+
} else if (row?.action === "delete" && row.key !== undefined) {
|
|
31
|
+
m.delete(row.key);
|
|
32
|
+
}
|
|
33
|
+
} catch (_) { /* skip malformed line */ }
|
|
34
|
+
}
|
|
35
|
+
} catch (_) { /* file missing or unreadable */ }
|
|
36
|
+
userKv[sub] = m;
|
|
37
|
+
return m;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function appendKvLog(sub, action, key, value) {
|
|
41
|
+
await mkdir(kvDir, { recursive: true });
|
|
42
|
+
const ts = new Date().toISOString();
|
|
43
|
+
const row = action === "update" ? { action, key, value, timestamp: ts } : { action, key, timestamp: ts };
|
|
44
|
+
await appendFile(kvPath(sub), JSON.stringify(row) + "\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function saveUserKv(sub, action, key, value) {
|
|
48
|
+
await appendKvLog(sub, action, key, value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const s3 = new S3Client({
|
|
52
|
+
accessKeyId: Bun.env.CLOUDFLARE_ACCESS_KEY_ID,
|
|
53
|
+
secretAccessKey: Bun.env.CLOUDFLARE_SECRET_ACCESS_KEY,
|
|
54
|
+
bucket: Bun.env.CLOUDFLARE_BUCKET_NAME,
|
|
55
|
+
endpoint: Bun.env.CLOUDFLARE_PUBLIC_URL
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export default {
|
|
59
|
+
async fetch(req) {
|
|
60
|
+
const url = new URL(req.url), key = url.pathname.slice(1), cb = `${url.origin}/callback`;
|
|
61
|
+
const token = req.headers.get("Cookie")?.match(/token=([^;]+)/)?.[1];
|
|
62
|
+
const user = (Bun.env.TEST_MODE === "1" && token === Bun.env.TEST_SESSION_ID) ? { sub: "test-user" } : (token ? sessions.get(token) : null);
|
|
63
|
+
|
|
64
|
+
if (req.method === "OPTIONS") return json(null, 204);
|
|
65
|
+
|
|
66
|
+
if (req.method === "GET") {
|
|
67
|
+
const p = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
|
|
68
|
+
if (p === "index.html" || /\.[a-z0-9]+$/i.test(p)) {
|
|
69
|
+
const f = Bun.file(join(publicDir, p));
|
|
70
|
+
if (await f.exists()) return new Response(f);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (url.pathname === "/login") return Response.redirect(`https://${Bun.env.AUTH0_DOMAIN}/authorize?${new URLSearchParams({ response_type: "code", client_id: Bun.env.AUTH0_CLIENT_ID, redirect_uri: cb, scope: "openid email profile" })}`);
|
|
75
|
+
|
|
76
|
+
if (url.pathname === "/callback") {
|
|
77
|
+
const code = url.searchParams.get("code");
|
|
78
|
+
if (!code) return json({ error: "No code" }, 400);
|
|
79
|
+
const tRes = await fetch(`https://${Bun.env.AUTH0_DOMAIN}/oauth/token`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "authorization_code", client_id: Bun.env.AUTH0_CLIENT_ID, client_secret: Bun.env.AUTH0_CLIENT_SECRET, code, redirect_uri: cb }) });
|
|
80
|
+
if (!tRes.ok) return json({ error: "Auth error" }, 401);
|
|
81
|
+
const tokens = await tRes.json();
|
|
82
|
+
const userInfo = await (await fetch(`https://${Bun.env.AUTH0_DOMAIN}/userinfo`, { headers: { Authorization: `Bearer ${tokens.access_token}` } })).json();
|
|
83
|
+
const sid = crypto.randomUUID();
|
|
84
|
+
sessions.set(sid, { ...userInfo, login_time: new Date().toISOString(), access_token_expires_at: tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000).toISOString() : null });
|
|
85
|
+
return redir(url.origin, `token=${sid}; HttpOnly; Path=/; Secure; SameSite=Lax; Max-Age=86400`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (url.pathname === "/me") return user ? json(user) : json({ error: "Unauthorized" }, 401);
|
|
89
|
+
if (!token || !user) return json({ error: "Unauthorized" }, 401);
|
|
90
|
+
if (url.pathname === "/logout") { sessions.delete(token); return redir(url.origin, "token=; Max-Age=0; Path=/"); }
|
|
91
|
+
|
|
92
|
+
const m = await getUserMap(user.sub);
|
|
93
|
+
switch (req.method) {
|
|
94
|
+
case "GET": return json(m.get(key) ?? null);
|
|
95
|
+
case "POST":
|
|
96
|
+
const val = await req.text();
|
|
97
|
+
const v = (() => { try { return JSON.parse(val); } catch { return val; } })();
|
|
98
|
+
m.set(key, v);
|
|
99
|
+
await saveUserKv(user.sub, "update", key, v);
|
|
100
|
+
return json({ key, value: v });
|
|
101
|
+
case "DELETE": m.delete(key); await saveUserKv(user.sub, "delete", key); return json({ status: "Deleted" });
|
|
102
|
+
case "PUT": await s3.write(`${user.sub}/${key}`, req.body); return json({ status: "Saved to R2" });
|
|
103
|
+
case "PURGE": await s3.delete(`${user.sub}/${key}`); return json({ status: "Deleted from R2" });
|
|
104
|
+
case "PROPFIND":
|
|
105
|
+
const list = await s3.list({ prefix: `${user.sub}/` });
|
|
106
|
+
const items = Array.isArray(list) ? list : (list?.contents || []);
|
|
107
|
+
return json(items.map((i) => ({ name: i.key?.split("/").pop() || i.name || i.Key, lastModified: i.lastModified || i.LastModified, size: i.size || i.Size || 0 })));
|
|
108
|
+
case "PATCH":
|
|
109
|
+
try {
|
|
110
|
+
if (!(await s3.exists(`${user.sub}/${key}`))) return json({ error: "File not found" }, 404);
|
|
111
|
+
const f = await s3.file(`${user.sub}/${key}`);
|
|
112
|
+
return f ? new Response(f.stream(), { headers: f.headers }) : json({ error: "File not found" }, 404);
|
|
113
|
+
} catch (e) { return json({ error: "File not found", details: e.message }, 404); }
|
|
114
|
+
default: return json({ error: "Method not allowed" }, 405);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
port: 3000,
|
|
118
|
+
tls: { cert: Bun.file(join(certsDir, "cert.pem")), key: Bun.file(join(certsDir, "key.pem")) }
|
|
119
|
+
};
|
package/__tests__/e2.test.js
DELETED
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
const fs = require('fs')
|
|
2
|
-
const path = require('path')
|
|
3
|
-
|
|
4
|
-
// Load client.js and custom.js into jsdom
|
|
5
|
-
const clientCode = fs.readFileSync(path.join(__dirname, '../public/client.js'), 'utf8')
|
|
6
|
-
const customCode = fs.readFileSync(path.join(__dirname, '../public/custom.js'), 'utf8')
|
|
7
|
-
|
|
8
|
-
describe('client.js', () => {
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
// Reset DOM
|
|
11
|
-
global.document.body.innerHTML = ''
|
|
12
|
-
global.document.head.innerHTML = ''
|
|
13
|
-
|
|
14
|
-
// Set api_url
|
|
15
|
-
global.window.api_url = 'https://localhost:3000'
|
|
16
|
-
|
|
17
|
-
// Clear window properties that might have descriptors
|
|
18
|
-
try {
|
|
19
|
-
delete global.window.custom
|
|
20
|
-
delete global.window.state
|
|
21
|
-
delete global.window.$
|
|
22
|
-
delete global.window.$$
|
|
23
|
-
delete global.window.$c
|
|
24
|
-
delete global.window.get
|
|
25
|
-
delete global.window.set
|
|
26
|
-
delete global.window.put
|
|
27
|
-
delete global.window.delete
|
|
28
|
-
delete global.window.purge
|
|
29
|
-
delete global.window.list
|
|
30
|
-
delete global.window.login
|
|
31
|
-
delete global.window.logout
|
|
32
|
-
delete global.window.download
|
|
33
|
-
delete global.window.initCustomElements
|
|
34
|
-
} catch (e) {
|
|
35
|
-
// Ignore errors
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Execute client.js code first (sets up Proxy)
|
|
39
|
-
eval(clientCode)
|
|
40
|
-
// Execute custom.js (defines window.custom components)
|
|
41
|
-
eval(customCode)
|
|
42
|
-
|
|
43
|
-
// Wait for initialization
|
|
44
|
-
return new Promise(resolve => setTimeout(resolve, 100))
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
describe('$ and $$ selectors', () => {
|
|
48
|
-
test('$ selects single element', () => {
|
|
49
|
-
global.document.body.innerHTML = '<div id="test">Hello</div>'
|
|
50
|
-
expect(window.$('#test').textContent).toBe('Hello')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
test('$$ selects multiple elements', () => {
|
|
54
|
-
global.document.body.innerHTML = '<div class="item">1</div><div class="item">2</div>'
|
|
55
|
-
const items = window.$$('.item')
|
|
56
|
-
expect(items.length).toBe(2)
|
|
57
|
-
expect(items[0].textContent).toBe('1')
|
|
58
|
-
expect(items[1].textContent).toBe('2')
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
test('$ returns null for non-existent element', () => {
|
|
62
|
-
global.document.body.innerHTML = '<div>Test</div>'
|
|
63
|
-
expect(window.$('#nonexistent')).toBeNull()
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
test('$$ returns empty NodeList for non-existent elements', () => {
|
|
67
|
-
global.document.body.innerHTML = '<div>Test</div>'
|
|
68
|
-
expect(window.$$('.nonexistent').length).toBe(0)
|
|
69
|
-
})
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
describe('window API functions', () => {
|
|
73
|
-
let originalFetch
|
|
74
|
-
let originalLocation
|
|
75
|
-
|
|
76
|
-
beforeEach(() => {
|
|
77
|
-
originalFetch = global.fetch
|
|
78
|
-
global.fetch = jest.fn()
|
|
79
|
-
originalLocation = global.window.location
|
|
80
|
-
delete global.window.location
|
|
81
|
-
global.window.location = { href: '' }
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
afterEach(() => {
|
|
85
|
-
global.fetch = originalFetch
|
|
86
|
-
global.window.location = originalLocation
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
test('window.get makes GET request with encoded key', async () => {
|
|
90
|
-
global.fetch.mockResolvedValueOnce({
|
|
91
|
-
ok: true,
|
|
92
|
-
json: () => Promise.resolve({ value: 'test' })
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
const result = await window.get('test key')
|
|
96
|
-
|
|
97
|
-
expect(global.fetch).toHaveBeenCalledWith(
|
|
98
|
-
`${window.api_url}/test%20key`,
|
|
99
|
-
expect.objectContaining({ method: 'GET' })
|
|
100
|
-
)
|
|
101
|
-
expect(result).toEqual({ value: 'test' })
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
test('window.set makes POST request with string value', async () => {
|
|
105
|
-
global.fetch.mockResolvedValueOnce({
|
|
106
|
-
ok: true,
|
|
107
|
-
json: () => Promise.resolve({ status: 'saved' })
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
const result = await window.set('test', 'value')
|
|
111
|
-
|
|
112
|
-
expect(global.fetch).toHaveBeenCalledWith(
|
|
113
|
-
`${window.api_url}/test`,
|
|
114
|
-
expect.objectContaining({
|
|
115
|
-
method: 'POST',
|
|
116
|
-
body: 'value'
|
|
117
|
-
})
|
|
118
|
-
)
|
|
119
|
-
expect(result).toEqual({ status: 'saved' })
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
test('window.set stringifies object values', async () => {
|
|
123
|
-
global.fetch.mockResolvedValueOnce({
|
|
124
|
-
ok: true,
|
|
125
|
-
json: () => Promise.resolve({ status: 'saved' })
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
await window.set('test', { key: 'value' })
|
|
129
|
-
|
|
130
|
-
expect(global.fetch).toHaveBeenCalledWith(
|
|
131
|
-
`${window.api_url}/test`,
|
|
132
|
-
expect.objectContaining({
|
|
133
|
-
method: 'POST',
|
|
134
|
-
body: JSON.stringify({ key: 'value' })
|
|
135
|
-
})
|
|
136
|
-
)
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
test('window.delete makes DELETE request', async () => {
|
|
140
|
-
global.fetch.mockResolvedValueOnce({
|
|
141
|
-
ok: true,
|
|
142
|
-
json: () => Promise.resolve({ status: 'deleted' })
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
const result = await window.delete('test')
|
|
146
|
-
|
|
147
|
-
expect(global.fetch).toHaveBeenCalledWith(
|
|
148
|
-
`${window.api_url}/test`,
|
|
149
|
-
expect.objectContaining({
|
|
150
|
-
method: 'DELETE'
|
|
151
|
-
})
|
|
152
|
-
)
|
|
153
|
-
expect(result).toEqual({ status: 'deleted' })
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
test('window.put makes PUT request with string body', async () => {
|
|
157
|
-
global.fetch.mockResolvedValueOnce({
|
|
158
|
-
ok: true,
|
|
159
|
-
json: () => Promise.resolve({ status: 'saved' })
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
const result = await window.put('test', 'content')
|
|
163
|
-
|
|
164
|
-
expect(global.fetch).toHaveBeenCalledWith(
|
|
165
|
-
`${window.api_url}/test`,
|
|
166
|
-
expect.objectContaining({
|
|
167
|
-
method: 'PUT',
|
|
168
|
-
body: 'content'
|
|
169
|
-
})
|
|
170
|
-
)
|
|
171
|
-
expect(result).toEqual({ status: 'saved' })
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
test('window.put stringifies object body', async () => {
|
|
175
|
-
global.fetch.mockResolvedValueOnce({
|
|
176
|
-
ok: true,
|
|
177
|
-
json: () => Promise.resolve({ status: 'saved' })
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
await window.put('test', { data: 'value' })
|
|
181
|
-
|
|
182
|
-
expect(global.fetch).toHaveBeenCalledWith(
|
|
183
|
-
`${window.api_url}/test`,
|
|
184
|
-
expect.objectContaining({
|
|
185
|
-
method: 'PUT',
|
|
186
|
-
body: JSON.stringify({ data: 'value' })
|
|
187
|
-
})
|
|
188
|
-
)
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
test('window.put handles Blob body', async () => {
|
|
192
|
-
const blob = new Blob(['test'], { type: 'text/plain' })
|
|
193
|
-
global.fetch.mockResolvedValueOnce({
|
|
194
|
-
ok: true,
|
|
195
|
-
json: () => Promise.resolve({ status: 'saved' })
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
await window.put('test', blob)
|
|
199
|
-
|
|
200
|
-
expect(global.fetch).toHaveBeenCalledWith(
|
|
201
|
-
`${window.api_url}/test`,
|
|
202
|
-
expect.objectContaining({
|
|
203
|
-
method: 'PUT',
|
|
204
|
-
body: blob
|
|
205
|
-
})
|
|
206
|
-
)
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
test('window.purge makes PURGE request', async () => {
|
|
210
|
-
global.fetch.mockResolvedValueOnce({
|
|
211
|
-
ok: true,
|
|
212
|
-
json: () => Promise.resolve({ status: 'deleted' })
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
const result = await window.purge('test')
|
|
216
|
-
|
|
217
|
-
expect(global.fetch).toHaveBeenCalledWith(
|
|
218
|
-
`${window.api_url}/test`,
|
|
219
|
-
expect.objectContaining({
|
|
220
|
-
method: 'PURGE'
|
|
221
|
-
})
|
|
222
|
-
)
|
|
223
|
-
expect(result).toEqual({ status: 'deleted' })
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
test('window.list makes PROPFIND request to base URL', async () => {
|
|
227
|
-
global.fetch.mockResolvedValueOnce({
|
|
228
|
-
ok: true,
|
|
229
|
-
json: () => Promise.resolve([{ name: 'file1' }])
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
const result = await window.list()
|
|
233
|
-
|
|
234
|
-
expect(global.fetch).toHaveBeenCalledWith(
|
|
235
|
-
`${window.api_url}/`,
|
|
236
|
-
expect.objectContaining({
|
|
237
|
-
method: 'PROPFIND'
|
|
238
|
-
})
|
|
239
|
-
)
|
|
240
|
-
expect(result).toEqual([{ name: 'file1' }])
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
test('window.login redirects to /login', () => {
|
|
244
|
-
window.login()
|
|
245
|
-
expect(global.window.location.href).toBe(`${window.api_url}/login`)
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
test('window.logout redirects to /logout', () => {
|
|
249
|
-
window.logout()
|
|
250
|
-
expect(global.window.location.href).toBe(`${window.api_url}/logout`)
|
|
251
|
-
})
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
describe('window.custom components', () => {
|
|
255
|
-
test('hello-world component is a function', () => {
|
|
256
|
-
expect(typeof window.custom['hello-world']).toBe('function')
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
test('hello-world component renders correctly', () => {
|
|
260
|
-
const result = window.custom['hello-world']('Test')
|
|
261
|
-
expect(result).toBe('Hello Test')
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
test('hello-world-2 component has prop and render methods', () => {
|
|
265
|
-
expect(window.custom['hello-world-2']).toBeDefined()
|
|
266
|
-
expect(typeof window.custom['hello-world-2'].prop).toBe('function')
|
|
267
|
-
expect(typeof window.custom['hello-world-2'].render).toBe('function')
|
|
268
|
-
})
|
|
269
|
-
|
|
270
|
-
test('hello-world-2 component prop works', () => {
|
|
271
|
-
const result = window.custom['hello-world-2'].prop('Test')
|
|
272
|
-
expect(result).toBe('Test World')
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
test('hello-world-2 component render works', () => {
|
|
276
|
-
const result = window.custom['hello-world-2'].render('Test')
|
|
277
|
-
expect(result).toBe('Test World')
|
|
278
|
-
})
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
describe('window.state proxy', () => {
|
|
283
|
-
test('state.set updates DOM elements with data attribute', async () => {
|
|
284
|
-
global.document.body.innerHTML = '<hello-world data="name"></hello-world>'
|
|
285
|
-
|
|
286
|
-
window.state.name = 'John'
|
|
287
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
288
|
-
|
|
289
|
-
const el = global.document.querySelector('hello-world')
|
|
290
|
-
expect(el.innerHTML).toBe('Hello John')
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
test('state.set updates multiple elements with same data attribute', async () => {
|
|
294
|
-
global.document.body.innerHTML = '<hello-world data="name"></hello-world><hello-world data="name"></hello-world>'
|
|
295
|
-
|
|
296
|
-
window.state.name = 'Jane'
|
|
297
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
298
|
-
|
|
299
|
-
const els = global.document.querySelectorAll('hello-world')
|
|
300
|
-
expect(els[0].innerHTML).toBe('Hello Jane')
|
|
301
|
-
expect(els[1].innerHTML).toBe('Hello Jane')
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
test('state.set works with object components', async () => {
|
|
305
|
-
global.document.body.innerHTML = '<hello-world-2 data="test"></hello-world-2>'
|
|
306
|
-
|
|
307
|
-
window.state.test = 'Hello'
|
|
308
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
309
|
-
|
|
310
|
-
const el = global.document.querySelector('hello-world-2')
|
|
311
|
-
expect(el.innerHTML).toBe('Hello World')
|
|
312
|
-
})
|
|
313
|
-
|
|
314
|
-
test('state.set stores value in proxy object', () => {
|
|
315
|
-
window.state.test = 'value'
|
|
316
|
-
expect(window.state.test).toBe('value')
|
|
317
|
-
})
|
|
318
|
-
|
|
319
|
-
test('state.set handles multiple properties', async () => {
|
|
320
|
-
global.document.body.innerHTML = '<hello-world data="a"></hello-world><hello-world data="b"></hello-world>'
|
|
321
|
-
|
|
322
|
-
window.state.a = 'A'
|
|
323
|
-
window.state.b = 'B'
|
|
324
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
325
|
-
|
|
326
|
-
expect(global.document.querySelector('[data="a"]').innerHTML).toBe('Hello A')
|
|
327
|
-
expect(global.document.querySelector('[data="b"]').innerHTML).toBe('Hello B')
|
|
328
|
-
})
|
|
329
|
-
|
|
330
|
-
test('state.set does not update elements without matching data attribute', async () => {
|
|
331
|
-
global.document.body.innerHTML = '<hello-world data="name"></hello-world><div data="other">Original</div>'
|
|
332
|
-
|
|
333
|
-
window.state.name = 'John'
|
|
334
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
335
|
-
|
|
336
|
-
expect(global.document.querySelector('[data="name"]').innerHTML).toBe('Hello John')
|
|
337
|
-
expect(global.document.querySelector('[data="other"]').innerHTML).toBe('Original')
|
|
338
|
-
})
|
|
339
|
-
})
|
|
340
|
-
})
|
package/__tests__/jest.config.js
DELETED