@yojahny/wp-design-library 0.1.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/.dockerignore +11 -0
- package/.env.example +7 -0
- package/Dockerfile +27 -0
- package/LICENSE +21 -0
- package/README.md +382 -0
- package/bin/library.mjs +19 -0
- package/docker/entrypoint.sh +11 -0
- package/docker-compose.yml +18 -0
- package/entries/.gitkeep +0 -0
- package/entries/ais-community-dark-depth/entry.md +76 -0
- package/entries/ais-community-dark-depth/strip.png +0 -0
- package/package.json +26 -0
- package/src/cli/add.mjs +163 -0
- package/src/cli/check.mjs +26 -0
- package/src/cli/export.mjs +41 -0
- package/src/cli/index.mjs +12 -0
- package/src/cli/refresh.mjs +63 -0
- package/src/cli/save.mjs +16 -0
- package/src/cli/serve.mjs +64 -0
- package/src/cli/ui.mjs +18 -0
- package/src/entry.mjs +69 -0
- package/src/index/build.mjs +78 -0
- package/src/index/embed.mjs +69 -0
- package/src/index/query.mjs +103 -0
- package/src/index/schema-vec.sql +4 -0
- package/src/index/schema.sql +10 -0
- package/src/ingest/draft.mjs +32 -0
- package/src/ingest/frames.mjs +93 -0
- package/src/ingest/measure.mjs +158 -0
- package/src/ingest/save.mjs +11 -0
- package/src/ingest/url-guard.mjs +76 -0
- package/src/mcp/http.mjs +72 -0
- package/src/mcp/prompts.mjs +147 -0
- package/src/mcp/resources.mjs +24 -0
- package/src/mcp/server.mjs +28 -0
- package/src/mcp/tools.mjs +152 -0
- package/src/paths.mjs +21 -0
- package/src/ui/app.js +34 -0
- package/src/ui/build.mjs +122 -0
- package/src/ui/serve.mjs +38 -0
- package/src/ui/templates/entry.html +31 -0
- package/src/ui/templates/index.html +32 -0
- package/src/vocab.mjs +34 -0
- package/tests/README.md +21 -0
- package/tests/checks/cli-check.sh +13 -0
- package/tests/checks/docker.sh +10 -0
- package/tests/checks/entry.sh +55 -0
- package/tests/checks/export.sh +16 -0
- package/tests/checks/fetch-on-start.sh +26 -0
- package/tests/checks/frames-dense.sh +26 -0
- package/tests/checks/http.sh +22 -0
- package/tests/checks/hygiene.sh +29 -0
- package/tests/checks/inbox.sh +27 -0
- package/tests/checks/index-degrade.sh +32 -0
- package/tests/checks/index.sh +36 -0
- package/tests/checks/ingest-mp4.sh +47 -0
- package/tests/checks/ingest.sh +39 -0
- package/tests/checks/licence-gate.sh +24 -0
- package/tests/checks/mcp-stdout.sh +31 -0
- package/tests/checks/measure.sh +75 -0
- package/tests/checks/minors.sh +84 -0
- package/tests/checks/prompt-add-entry.sh +28 -0
- package/tests/checks/resources.sh +44 -0
- package/tests/checks/rrf.sh +120 -0
- package/tests/checks/seed-sync.sh +22 -0
- package/tests/checks/similar.sh +62 -0
- package/tests/checks/ui-build.sh +59 -0
- package/tests/checks/vocab.sh +30 -0
- package/tests/fixtures/entry-ok/entry.md +41 -0
- package/tests/fixtures/entry-ok/strip.png +0 -0
- package/tests/fixtures/page/index.html +47 -0
- package/tests/fixtures/three-frame.webp +0 -0
- package/tests/run.sh +10 -0
- package/vocab.yaml +16 -0
package/src/vocab.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
import { paths } from './paths.mjs';
|
|
4
|
+
|
|
5
|
+
export const FACETS = ['role', 'feel', 'motion', 'source', 'tier'];
|
|
6
|
+
|
|
7
|
+
export function loadVocab(file = paths().vocab) {
|
|
8
|
+
const raw = yaml.load(fs.readFileSync(file, 'utf8')) ?? {};
|
|
9
|
+
const facets = {};
|
|
10
|
+
for (const f of FACETS) {
|
|
11
|
+
if (!Array.isArray(raw[f])) throw new Error(`vocab.yaml: facet "${f}" must be a list`);
|
|
12
|
+
facets[f] = new Set(raw[f]);
|
|
13
|
+
}
|
|
14
|
+
const aliases = new Map();
|
|
15
|
+
if (raw.aliases !== undefined) {
|
|
16
|
+
if (typeof raw.aliases !== 'object' || Array.isArray(raw.aliases) || raw.aliases === null) {
|
|
17
|
+
throw new Error('vocab.yaml: aliases must map canonical term -> list');
|
|
18
|
+
}
|
|
19
|
+
for (const [canon, list] of Object.entries(raw.aliases)) {
|
|
20
|
+
if (!Array.isArray(list)) {
|
|
21
|
+
throw new Error(`vocab.yaml: aliases.${canon} must be list`);
|
|
22
|
+
}
|
|
23
|
+
for (const a of list) aliases.set(a, canon);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { facets, aliases };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveTerm(vocab, facet, term) {
|
|
30
|
+
const set = vocab.facets[facet];
|
|
31
|
+
if (!set) return null;
|
|
32
|
+
const canon = vocab.aliases.get(term) ?? term;
|
|
33
|
+
return set.has(canon) ? canon : null;
|
|
34
|
+
}
|
package/tests/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Check ports
|
|
2
|
+
|
|
3
|
+
A check that starts `serve --http` picks its own `PORT`, unique across the suite, so
|
|
4
|
+
checks can run concurrently without colliding on a listening socket.
|
|
5
|
+
|
|
6
|
+
| Check | Port |
|
|
7
|
+
|---|---|
|
|
8
|
+
| fetch-on-start.sh | 4189 |
|
|
9
|
+
| minors.sh | 4190 |
|
|
10
|
+
| similar.sh | 4191 |
|
|
11
|
+
| ui-build.sh | 4193 |
|
|
12
|
+
| hygiene.sh | 4195 |
|
|
13
|
+
| seed-sync.sh | 4196 |
|
|
14
|
+
| docker.sh | 4198 |
|
|
15
|
+
| http.sh | 4199 |
|
|
16
|
+
|
|
17
|
+
A check that never calls `serve --http` (everything else under `tests/checks/`) needs
|
|
18
|
+
no port; it exercises `add`/`export`/`index`/`check`/`save` directly or drives `serve`
|
|
19
|
+
(no `--http`) over stdio.
|
|
20
|
+
|
|
21
|
+
A new serve-based check takes the next free port below 4191.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
5
|
+
mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
|
|
6
|
+
LIBRARY_DATA="$tmp" node bin/library.mjs check | grep -q "1/1 entries ok" || { echo "FAIL: clean corpus"; exit 1; }
|
|
7
|
+
sed -i 's/kind: open/kind: paid/; s/tier: inspiration/tier: ported/' "$tmp/entries/entry-ok/entry.md"
|
|
8
|
+
if LIBRARY_DATA="$tmp" node bin/library.mjs check >"$tmp/out" 2>&1; then echo "FAIL: gate not enforced by check"; exit 1; fi
|
|
9
|
+
grep -q "entry-ok: licence gate" "$tmp/out" || { echo "FAIL: error line shape"; cat "$tmp/out"; exit 1; }
|
|
10
|
+
mkdir -p "$tmp/entries/broken"; echo "no frontmatter here" > "$tmp/entries/broken/entry.md"
|
|
11
|
+
if LIBRARY_DATA="$tmp" node bin/library.mjs check >"$tmp/out" 2>&1; then echo "FAIL: parse error not detected"; exit 1; fi
|
|
12
|
+
grep -q "^broken: " "$tmp/out" || { echo "FAIL: parse error line shape"; cat "$tmp/out"; exit 1; }
|
|
13
|
+
echo PASS
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
command -v docker >/dev/null || { echo "SKIP: docker not installed"; exit 0; }
|
|
5
|
+
docker build -q -t wp-design-library:check . >/dev/null
|
|
6
|
+
cid=""; trap '[ -n "$cid" ] && docker stop "$cid" >/dev/null 2>&1 || true' EXIT
|
|
7
|
+
cid=$(docker run -d --rm -e LIBRARY_TOKEN=t0k -p 4198:4180 wp-design-library:check)
|
|
8
|
+
for i in $(seq 1 60); do curl -sf localhost:4198/healthz >/dev/null 2>&1 && break; sleep 1; done
|
|
9
|
+
curl -sf localhost:4198/healthz | grep -q '"ok":true' || { echo "FAIL: container healthz"; docker logs "$cid"; exit 1; }
|
|
10
|
+
echo PASS
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
node --input-type=module -e '
|
|
5
|
+
import { parseEntry, validateEntry } from "./src/entry.mjs";
|
|
6
|
+
import { loadVocab } from "./src/vocab.mjs";
|
|
7
|
+
const must = (c, m) => { if (!c) { console.error("FAIL: " + m); process.exit(1); } };
|
|
8
|
+
const v = loadVocab();
|
|
9
|
+
const e = parseEntry("tests/fixtures/entry-ok");
|
|
10
|
+
must(e.slug === "entry-ok", "slug from frontmatter");
|
|
11
|
+
must(e.body.includes("## Section roster"), "body parsed");
|
|
12
|
+
let r = validateEntry(e, v);
|
|
13
|
+
must(r.ok, "fixture validates: " + r.errors.join("; "));
|
|
14
|
+
e.fm.roles = ["hero", "neon"];
|
|
15
|
+
r = validateEntry(e, v);
|
|
16
|
+
must(!r.ok && r.errors.some(x => x.includes("role") && x.includes("neon")), "unknown tag names facet and term");
|
|
17
|
+
e.fm.roles = ["plans"];
|
|
18
|
+
r = validateEntry(e, v);
|
|
19
|
+
must(r.ok && e.fm.roles[0] === "offer", "alias resolved to canonical in place");
|
|
20
|
+
e.fm.title = "";
|
|
21
|
+
r = validateEntry(e, v);
|
|
22
|
+
must(!r.ok && r.errors.some(x => x.includes("title")), "blank required field named");
|
|
23
|
+
'
|
|
24
|
+
|
|
25
|
+
# Test CRLF line endings support
|
|
26
|
+
tmpdir=$(mktemp -d)
|
|
27
|
+
trap "rm -rf $tmpdir" EXIT
|
|
28
|
+
mkdir -p "$tmpdir/entry-ok"
|
|
29
|
+
cp tests/fixtures/entry-ok/entry.md "$tmpdir/entry-ok/entry.md"
|
|
30
|
+
sed -i "s/$/\r/" "$tmpdir/entry-ok/entry.md"
|
|
31
|
+
cp tests/fixtures/entry-ok/strip.png "$tmpdir/entry-ok/strip.png"
|
|
32
|
+
node --input-type=module -e "
|
|
33
|
+
import { parseEntry, validateEntry } from './src/entry.mjs';
|
|
34
|
+
import { loadVocab } from './src/vocab.mjs';
|
|
35
|
+
const must = (c, m) => { if (!c) { console.error('FAIL: ' + m); process.exit(1); } };
|
|
36
|
+
const v = loadVocab();
|
|
37
|
+
const e = parseEntry('$tmpdir/entry-ok');
|
|
38
|
+
must(e.slug === 'entry-ok', 'CRLF: slug parsed');
|
|
39
|
+
must(validateEntry(e, v).ok, 'CRLF: validates');
|
|
40
|
+
"
|
|
41
|
+
|
|
42
|
+
# slug must equal the directory name
|
|
43
|
+
mkdir -p "$tmpdir/other-name"
|
|
44
|
+
cp tests/fixtures/entry-ok/entry.md tests/fixtures/entry-ok/strip.png "$tmpdir/other-name/"
|
|
45
|
+
node --input-type=module -e "
|
|
46
|
+
import { parseEntry, validateEntry } from './src/entry.mjs';
|
|
47
|
+
import { loadVocab } from './src/vocab.mjs';
|
|
48
|
+
const must = (c, m) => { if (!c) { console.error('FAIL: ' + m); process.exit(1); } };
|
|
49
|
+
const v = loadVocab();
|
|
50
|
+
const e = parseEntry('$tmpdir/other-name');
|
|
51
|
+
const r = validateEntry(e, v);
|
|
52
|
+
must(!r.ok && r.errors.some(x => x.includes('slug: must equal')), 'slug mismatch refused');
|
|
53
|
+
"
|
|
54
|
+
|
|
55
|
+
echo PASS
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
node_bin=$(command -v node)
|
|
5
|
+
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
6
|
+
mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
|
|
7
|
+
LIBRARY_DATA="$tmp" node bin/library.mjs export > "$tmp/out.tar" 2>"$tmp/err"
|
|
8
|
+
tar -tf "$tmp/out.tar" | grep -q 'entries/entry-ok/entry.md' || { echo "FAIL: tar content"; exit 1; }
|
|
9
|
+
grep -q "export: 1 entries" "$tmp/err" || { echo "FAIL: count on stderr"; exit 1; }
|
|
10
|
+
|
|
11
|
+
emptybin="$tmp/emptybin"; mkdir -p "$emptybin"
|
|
12
|
+
code=0
|
|
13
|
+
PATH="$emptybin" LIBRARY_DATA="$tmp" "$node_bin" bin/library.mjs export > "$tmp/out2.tar" 2>"$tmp/err2" || code=$?
|
|
14
|
+
[ "$code" -ne 0 ] || { echo "FAIL: missing tar should exit non-zero"; exit 1; }
|
|
15
|
+
grep -q "tar is required" "$tmp/err2" || { echo "FAIL: missing tar message"; cat "$tmp/err2"; exit 1; }
|
|
16
|
+
echo PASS
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# LIBRARY_FETCH_MODELS=1 makes `serve --http` attempt a model fetch between the seed
|
|
3
|
+
# sync and the index build; the attempt is logged and never blocks the server from
|
|
4
|
+
# coming up, even when it fails. No network download is asserted here: LIBRARY_MODELS
|
|
5
|
+
# sits under a regular file, so fetchModels fails fast on its first cache write
|
|
6
|
+
# (ENOTDIR, for any uid including root) instead of pulling real model weights.
|
|
7
|
+
# (HF_HUB_OFFLINE=1 is also set for intent, but @huggingface/transformers does not
|
|
8
|
+
# honour it — see task-7-report.md.)
|
|
9
|
+
set -eu
|
|
10
|
+
cd "$(dirname "$0")/../.."
|
|
11
|
+
tmp=$(mktemp -d); pid=""; trap 'rm -rf "$tmp"; [ -n "$pid" ] && kill $pid 2>/dev/null || true' EXIT
|
|
12
|
+
mkdir -p "$tmp/data"; : > "$tmp/notadir"
|
|
13
|
+
export LIBRARY_SEED="$tmp/noseed" LIBRARY_DATA="$tmp/data" LIBRARY_MODELS="$tmp/notadir/models" LIBRARY_EMBED=stub LIBRARY_TOKEN=t0k HF_HUB_OFFLINE=1
|
|
14
|
+
|
|
15
|
+
LIBRARY_FETCH_MODELS=1 PORT=4189 node bin/library.mjs serve --http >"$tmp/log" 2>&1 & pid=$!
|
|
16
|
+
for i in $(seq 1 60); do curl -sf localhost:4189/healthz >/dev/null 2>&1 && break; sleep 0.2; done
|
|
17
|
+
curl -sf localhost:4189/healthz | grep -q '"ok":true' || { echo "FAIL: healthz not up after a model fetch attempt"; cat "$tmp/log"; exit 1; }
|
|
18
|
+
grep -q "models: fetch start" "$tmp/log" || { echo "FAIL: fetch-start line missing with LIBRARY_FETCH_MODELS=1"; cat "$tmp/log"; exit 1; }
|
|
19
|
+
kill $pid; wait $pid 2>/dev/null || true; pid=""
|
|
20
|
+
|
|
21
|
+
LIBRARY_FETCH_MODELS=0 PORT=4189 node bin/library.mjs serve --http >"$tmp/log2" 2>&1 & pid=$!
|
|
22
|
+
for i in $(seq 1 60); do curl -sf localhost:4189/healthz >/dev/null 2>&1 && break; sleep 0.2; done
|
|
23
|
+
curl -sf localhost:4189/healthz | grep -q '"ok":true' || { echo "FAIL: healthz not up with LIBRARY_FETCH_MODELS=0"; cat "$tmp/log2"; exit 1; }
|
|
24
|
+
grep -q "models: fetch" "$tmp/log2" && { echo "FAIL: fetch line present with LIBRARY_FETCH_MODELS=0"; cat "$tmp/log2"; exit 1; }
|
|
25
|
+
kill $pid; wait $pid 2>/dev/null || true; pid=""
|
|
26
|
+
echo PASS
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
5
|
+
mkdir -p "$tmp/f" "$tmp/data/entries"
|
|
6
|
+
node --input-type=module -e 'import sharp from "sharp"; for (let i = 0; i < 60; i++) await sharp({create:{width:40,height:20,channels:3,background:{r:i*4,g:0,b:0}}}).png().toFile(process.argv[1] + "/" + i + ".png")' "$tmp/f"
|
|
7
|
+
ffmpeg -y -loglevel error -framerate 10 -i "$tmp/f/%d.png" -loop 0 -lossless 1 "$tmp/sixty.webp" # 60 frames, 100 ms each = 6 s
|
|
8
|
+
node --input-type=module -e '
|
|
9
|
+
import { sampleFrames } from "./src/ingest/frames.mjs";
|
|
10
|
+
const must = (c, m) => { if (!c) { console.error("FAIL: " + m); process.exit(1); } };
|
|
11
|
+
let r = await sampleFrames(process.argv[1], process.argv[2]);
|
|
12
|
+
must(r.frames === 60, "frame count " + r.frames);
|
|
13
|
+
must(r.sampled.length === 12, "6 s → 12 frames by duration, got " + r.sampled.length);
|
|
14
|
+
r = await sampleFrames(process.argv[1], process.argv[2], { frames: 36 });
|
|
15
|
+
must(r.sampled.length === 36, "explicit 36");
|
|
16
|
+
must(r.sampled[0] === 0 && r.sampled[35] === 59, "spread first..last");
|
|
17
|
+
r = await sampleFrames(process.argv[1], process.argv[3], { frames: 1 });
|
|
18
|
+
must(r.sampled.length === 2, "frames:1 must not divide by zero, got " + r.sampled.length);
|
|
19
|
+
' "$tmp/sixty.webp" "$tmp/strip.png" "$tmp/strip1.png"
|
|
20
|
+
node --input-type=module -e "import sharp from 'sharp'; const m = await sharp('$tmp/strip.png').metadata(); if (m.width !== 40*6) { console.error('FAIL: 36 tiles use 6 columns, width ' + m.width); process.exit(1); }"
|
|
21
|
+
# --frames 1 is invalid at the CLI too (schema requires 6..36) and must refuse before ingesting
|
|
22
|
+
if out=$(LIBRARY_DATA="$tmp/data" node bin/library.mjs add tests/fixtures/three-frame.webp --slug x --title x --source own --license x --frames 1 2>&1); then
|
|
23
|
+
echo "FAIL: --frames 1 should refuse and exit non-zero"; echo "$out"; exit 1
|
|
24
|
+
fi
|
|
25
|
+
echo "$out" | grep -q '"refused"' || { echo "FAIL: --frames 1 refusal missing refused field"; echo "$out"; exit 1; }
|
|
26
|
+
echo PASS
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; trap 'rm -rf "$tmp"; kill $pid 2>/dev/null || true' EXIT # LIBRARY_SEED isolates the check from the repo corpus
|
|
5
|
+
mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
|
|
6
|
+
LIBRARY_DATA="$tmp" node bin/library.mjs index >/dev/null 2>&1
|
|
7
|
+
# refuses without token
|
|
8
|
+
if LIBRARY_DATA="$tmp" PORT=4199 timeout 5 node bin/library.mjs serve --http >"$tmp/o" 2>&1; then echo "FAIL: started without token"; exit 1; fi
|
|
9
|
+
grep -q "LIBRARY_TOKEN" "$tmp/o" || { echo "FAIL: refusal names LIBRARY_TOKEN"; exit 1; }
|
|
10
|
+
LIBRARY_DATA="$tmp" PORT=4199 LIBRARY_TOKEN=t0k node bin/library.mjs serve --http >"$tmp/log" 2>&1 & pid=$!
|
|
11
|
+
for i in $(seq 1 30); do curl -sf localhost:4199/healthz >/dev/null 2>&1 && break; sleep 0.2; done
|
|
12
|
+
curl -sf localhost:4199/healthz | grep -q '"entries":1' || { echo "FAIL: healthz"; cat "$tmp/log"; exit 1; }
|
|
13
|
+
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST localhost:4199/mcp -H 'content-type: application/json' -d '{}')
|
|
14
|
+
[ "$code" = "401" ] || { echo "FAIL: expected 401 without bearer, got $code"; exit 1; }
|
|
15
|
+
body='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
|
|
16
|
+
curl -s -X POST localhost:4199/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$body" \
|
|
17
|
+
| grep -q 'wp-design-library' || { echo "FAIL: initialize over http"; exit 1; }
|
|
18
|
+
# http add is confined to <data>/inbox
|
|
19
|
+
body_add='{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add","arguments":{"input":"/etc/hostname","slug":"x","title":"x","source":{"kind":"open","license":"MIT"}}}}'
|
|
20
|
+
curl -s -X POST localhost:4199/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$body_add" \
|
|
21
|
+
| grep -q '"refused"' || { echo "FAIL: http add did not refuse a file outside the inbox"; exit 1; }
|
|
22
|
+
echo PASS
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; pid=""; trap 'rm -rf "$tmp"; [ -n "$pid" ] && kill $pid 2>/dev/null || true' EXIT
|
|
5
|
+
mkdir -p "$tmp/entries" "$tmp/inbox" "$tmp/outside"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
|
|
6
|
+
# version comes from package.json
|
|
7
|
+
v=$(node -p "require('./package.json').version")
|
|
8
|
+
grep -rEq "(version|tool)[^\n]*['\"][0-9]+\.[0-9]+\.[0-9]+['\"]" src/ && { echo "FAIL: hard-coded version literal in src/"; exit 1; }
|
|
9
|
+
LIBRARY_DATA="$tmp" PORT=4195 LIBRARY_TOKEN=t0k node bin/library.mjs serve --http >"$tmp/log" 2>&1 & pid=$!
|
|
10
|
+
for i in $(seq 1 30); do curl -sf localhost:4195/healthz >/dev/null 2>&1 && break; sleep 0.2; done
|
|
11
|
+
curl -sf localhost:4195/healthz | grep -q "\"version\":\"$v\"" || { echo "FAIL: healthz version"; exit 1; }
|
|
12
|
+
# a token of a different length is refused without a 500
|
|
13
|
+
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST localhost:4195/mcp -H 'authorization: Bearer t0' -H 'content-type: application/json' -d '{}')
|
|
14
|
+
[ "$code" = "401" ] || { echo "FAIL: short token → $code"; exit 1; }
|
|
15
|
+
# symlink inside inbox pointing outside is refused over http
|
|
16
|
+
cp tests/fixtures/three-frame.webp "$tmp/outside/real.webp"; ln -s "$tmp/outside/real.webp" "$tmp/inbox/link.webp"
|
|
17
|
+
init='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}'
|
|
18
|
+
curl -s -X POST localhost:4195/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$init" >/dev/null
|
|
19
|
+
call="{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"add\",\"arguments\":{\"input\":\"$tmp/inbox/link.webp\",\"slug\":\"lnk\",\"title\":\"x\",\"source\":{\"kind\":\"open\",\"license\":\"MIT\"}}}}"
|
|
20
|
+
curl -s -X POST localhost:4195/mcp -H 'authorization: Bearer t0k' -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' -d "$call" | grep -q '"refused"' || { echo "FAIL: symlink escape not refused"; exit 1; }
|
|
21
|
+
# atomic rebuild: no partial index visible; the temp file is gone afterwards
|
|
22
|
+
LIBRARY_DATA="$tmp" node bin/library.mjs index >/dev/null 2>&1
|
|
23
|
+
[ ! -e "$tmp/.library/index.sqlite.tmp" ] || { echo "FAIL: temp index left behind"; exit 1; }
|
|
24
|
+
grep -q "renameSync" src/index/build.mjs || { echo "FAIL: build is not rename-based"; exit 1; }
|
|
25
|
+
# a failed rebuild (renameSync onto a directory) closes the tmp handle, removes the tmp file, and exits non-zero
|
|
26
|
+
rm -rf "$tmp/.library/index.sqlite"; mkdir -p "$tmp/.library/index.sqlite"
|
|
27
|
+
LIBRARY_DATA="$tmp" node bin/library.mjs index >/dev/null 2>&1 && { echo "FAIL: index build over a directory target exited 0"; exit 1; }
|
|
28
|
+
[ ! -e "$tmp/.library/index.sqlite.tmp" ] || { echo "FAIL: temp index left behind after a failed rebuild"; exit 1; }
|
|
29
|
+
echo PASS
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
5
|
+
mkdir -p "$tmp/inbox" "$tmp/entries"
|
|
6
|
+
cp tests/fixtures/three-frame.webp "$tmp/inbox/Acme Dark Hero.webp"
|
|
7
|
+
cp tests/fixtures/three-frame.webp "$tmp/inbox/with-sidecar.webp"
|
|
8
|
+
echo '{"kind":"open","license":"MIT","url":"https://x.example","title":"Sidecar title"}' > "$tmp/inbox/with-sidecar.webp.json"
|
|
9
|
+
cp tests/fixtures/three-frame.webp "$tmp/inbox/no-meta.webp"
|
|
10
|
+
out=$(LIBRARY_DATA="$tmp" node bin/library.mjs add --inbox --source public-site --license "description only" 2>&1)
|
|
11
|
+
echo "$out" | grep -q '"slug":"acme-dark-hero","status":"drafted"' || { echo "FAIL: slug from filename"; echo "$out"; exit 1; }
|
|
12
|
+
grep -q "title: Sidecar title" "$tmp/entries/with-sidecar/entry.md" || { echo "FAIL: sidecar title"; exit 1; }
|
|
13
|
+
grep -q "kind: open" "$tmp/entries/with-sidecar/entry.md" || { echo "FAIL: sidecar kind"; exit 1; }
|
|
14
|
+
grep -q "kind: public-site" "$tmp/entries/no-meta/entry.md" || { echo "FAIL: flags as fallback"; exit 1; }
|
|
15
|
+
out=$(LIBRARY_DATA="$tmp" node bin/library.mjs add --inbox --source public-site --license x 2>&1)
|
|
16
|
+
echo "$out" | grep -c '"status":"skipped"' | grep -q '^3$' || { echo "FAIL: second run skips all three"; echo "$out"; exit 1; }
|
|
17
|
+
out=$(LIBRARY_DATA="$tmp" node bin/library.mjs add --inbox 2>&1 || true) # no flags, no sidecar → refused, not crash
|
|
18
|
+
cp tests/fixtures/three-frame.webp "$tmp/inbox/fourth.webp"
|
|
19
|
+
out=$(LIBRARY_DATA="$tmp" node bin/library.mjs add --inbox 2>&1 || true)
|
|
20
|
+
echo "$out" | grep -q '"slug":"fourth","status":"refused"' || { echo "FAIL: missing metadata is a per-file refusal"; echo "$out"; exit 1; }
|
|
21
|
+
|
|
22
|
+
cp tests/fixtures/three-frame.webp "$tmp/inbox/bad-side.webp"
|
|
23
|
+
echo 'null' > "$tmp/inbox/bad-side.webp.json"
|
|
24
|
+
out=$(LIBRARY_DATA="$tmp" node bin/library.mjs add --inbox --source public-site --license x 2>&1 || true)
|
|
25
|
+
echo "$out" | grep -q '"slug":"bad-side","status":"refused"' || { echo "FAIL: non-object sidecar is a per-file refusal"; echo "$out"; exit 1; }
|
|
26
|
+
echo "$out" | grep -c '^{' | grep -q '^5$' || { echo "FAIL: loop died instead of reporting every file"; echo "$out"; exit 1; }
|
|
27
|
+
echo PASS
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
5
|
+
mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
|
|
6
|
+
# models absent → fts only, skip reported, exit 0
|
|
7
|
+
out=$(LIBRARY_DATA="$tmp" LIBRARY_EMBED= node bin/library.mjs index 2>&1)
|
|
8
|
+
echo "$out" | grep -qE "arms: fts([^,]|$)" || { echo "FAIL: arms without models; got: $out"; exit 1; }
|
|
9
|
+
echo "$out" | grep -q "embeddings: skipped (models absent" || { echo "FAIL: skip line; got: $out"; exit 1; }
|
|
10
|
+
# stub → both arms, vectors stored with the right dims
|
|
11
|
+
out=$(LIBRARY_DATA="$tmp" LIBRARY_EMBED=stub node bin/library.mjs index 2>&1)
|
|
12
|
+
echo "$out" | grep -q "arms: fts,vec" || { echo "FAIL: arms with stub; got: $out"; exit 1; }
|
|
13
|
+
LIBRARY_DATA="$tmp" node --input-type=module -e '
|
|
14
|
+
import { openIndex } from "./src/index/query.mjs"; import { paths } from "./src/paths.mjs";
|
|
15
|
+
const db = openIndex(paths().index);
|
|
16
|
+
const must = (c, m) => { if (!c) { console.error("FAIL: " + m); process.exit(1); } };
|
|
17
|
+
must(db.vec === true, "sqlite-vec loaded");
|
|
18
|
+
must(db.prepare("select count(*) c from vec_text").get().c === 1, "one text vector");
|
|
19
|
+
must(db.prepare("select count(*) c from vec_image").get().c === 1, "one image vector");
|
|
20
|
+
must(db.prepare("select vec_length(embedding) n from vec_text").get().n === 384, "text dims 384");
|
|
21
|
+
must(db.prepare("select vec_length(embedding) n from vec_image").get().n === 768, "image dims 768");
|
|
22
|
+
'
|
|
23
|
+
# stub is deterministic
|
|
24
|
+
LIBRARY_DATA="$tmp" node --input-type=module -e '
|
|
25
|
+
import { getEmbedder } from "./src/index/embed.mjs";
|
|
26
|
+
const e = await getEmbedder({ models: "/nonexistent", mode: "stub" });
|
|
27
|
+
const a = await e.text("dark hero"), b = await e.text("dark hero"), c = await e.text("light hero");
|
|
28
|
+
const must = (x, m) => { if (!x) { console.error("FAIL: " + m); process.exit(1); } };
|
|
29
|
+
must(a.length === 384 && a.every((v, i) => v === b[i]), "stub deterministic");
|
|
30
|
+
must(!a.every((v, i) => v === c[i]), "stub distinguishes texts");
|
|
31
|
+
'
|
|
32
|
+
echo PASS
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
5
|
+
mkdir -p "$tmp/entries"
|
|
6
|
+
cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
|
|
7
|
+
cp -r tests/fixtures/entry-ok "$tmp/entries/entry-two"
|
|
8
|
+
sed -i 's/slug: entry-ok/slug: entry-two/; s/Fixture hero with parallax bed/Pricing table with three plans/; s/roles: \[hero, closing\]/roles: [offer]/; s/at: 2026-09-14/at: 2026-01-15/' "$tmp/entries/entry-two/entry.md"
|
|
9
|
+
cp -r tests/fixtures/entry-ok "$tmp/entries/entry-bad"
|
|
10
|
+
sed -i 's/slug: entry-ok/slug: entry-bad/; s/kind: open/kind: paid/; s/tier: inspiration/tier: ported/' "$tmp/entries/entry-bad/entry.md"
|
|
11
|
+
LIBRARY_DATA="$tmp" node bin/library.mjs index >"$tmp/out" 2>&1
|
|
12
|
+
grep -q "indexed 2" "$tmp/out" || { echo "FAIL: count"; cat "$tmp/out"; exit 1; }
|
|
13
|
+
grep -q "refused entry-bad" "$tmp/out" || { echo "FAIL: refusal not reported"; cat "$tmp/out"; exit 1; }
|
|
14
|
+
LIBRARY_DATA="$tmp" node --input-type=module -e '
|
|
15
|
+
import { openIndex, search, getEntry } from "./src/index/query.mjs";
|
|
16
|
+
import { paths } from "./src/paths.mjs";
|
|
17
|
+
const must = (c, m) => { if (!c) { console.error("FAIL: " + m); process.exit(1); } };
|
|
18
|
+
const db = openIndex(paths().index);
|
|
19
|
+
let r = await search(db, { query: "parallax" });
|
|
20
|
+
must(r.arms.length === 1 && r.arms[0] === "fts", "arms reports fts only");
|
|
21
|
+
must(r.hits[0].slug === "entry-ok", "keyword hit ranks first");
|
|
22
|
+
r = await search(db, { query: "plans" });
|
|
23
|
+
must(r.hits.some(h => h.slug === "entry-two"), "title matched");
|
|
24
|
+
r = await search(db, { query: "", filters: { role: ["offer"] } });
|
|
25
|
+
must(r.hits.length === 1 && r.hits[0].slug === "entry-two", "facet filter alone");
|
|
26
|
+
r = await search(db, { query: "", filters: { role: ["hero"], feel: ["depth"] } });
|
|
27
|
+
must(r.hits.length === 1 && r.hits[0].slug === "entry-ok", "AND across facets");
|
|
28
|
+
r = await search(db, { query: "" });
|
|
29
|
+
must(r.hits[0].slug === "entry-ok", "newest first");
|
|
30
|
+
r = await search(db, { query: "parallax", filters: { role: ["offer"] } });
|
|
31
|
+
must(r.hits.length === 1 && r.hits[0].slug === "entry-two", "query and facet filter combined");
|
|
32
|
+
const e = getEntry(db, "entry-two");
|
|
33
|
+
must(e && e.fm.roles[0] === "offer" && e.body.includes("Section roster"), "getEntry returns fm and body");
|
|
34
|
+
must(getEntry(db, "nope") === null, "missing slug is null");
|
|
35
|
+
'
|
|
36
|
+
echo PASS
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
command -v ffmpeg >/dev/null || { echo "SKIP: ffmpeg not installed"; exit 0; }
|
|
5
|
+
NODE=$(command -v node)
|
|
6
|
+
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
7
|
+
mkdir -p "$tmp/f" "$tmp/entries"
|
|
8
|
+
for i in $(seq 0 39); do node --input-type=module -e "import sharp from 'sharp'; await sharp({create:{width:64,height:32,channels:3,background:{r:0,g:$((i*6)),b:0}}}).png().toFile('$tmp/f/$i.png')"; done
|
|
9
|
+
ffmpeg -y -loglevel error -framerate 10 -i "$tmp/f/%d.png" -pix_fmt yuv420p "$tmp/clip.mp4" # 4 s
|
|
10
|
+
out=$(LIBRARY_DATA="$tmp" node bin/library.mjs add "$tmp/clip.mp4" --slug clip --title Clip --source own --license "own work")
|
|
11
|
+
echo "$out" | grep -q '"frames":8' || { echo "FAIL: 4 s at 2 fps → 8 frames; got: $out"; exit 1; }
|
|
12
|
+
[ -f "$tmp/entries/clip/strip.png" ] || { echo "FAIL: strip"; exit 1; }
|
|
13
|
+
# absent ffmpeg is a refusal naming the binary
|
|
14
|
+
out=$(PATH="$tmp/nobin" LIBRARY_DATA="$tmp" "$NODE" bin/library.mjs add "$tmp/clip.mp4" --slug clip2 --title Clip --source own --license x 2>&1 || true)
|
|
15
|
+
echo "$out" | grep -q 'ffmpeg is required' || { echo "FAIL: refusal names ffmpeg; got: $out"; exit 1; }
|
|
16
|
+
[ ! -e "$tmp/entries/clip2" ] || { echo "FAIL: refused add (absent ffmpeg) created entries/clip2"; exit 1; }
|
|
17
|
+
# a broken/unreadable video is a named refusal too, and never leaks its temp frame dir
|
|
18
|
+
sysTmp=$(node -p "require('os').tmpdir()")
|
|
19
|
+
before=$(find "$sysTmp" -maxdepth 1 -name 'wpdl-*' 2>/dev/null | sort)
|
|
20
|
+
: > "$tmp/broken.mp4"
|
|
21
|
+
if out=$(LIBRARY_DATA="$tmp" node bin/library.mjs add "$tmp/broken.mp4" --slug broken --title Broken --source own --license x 2>&1); then
|
|
22
|
+
echo "FAIL: broken mp4 should refuse and exit non-zero"; echo "$out"; exit 1
|
|
23
|
+
fi
|
|
24
|
+
echo "$out" | grep -q '"refused":"ffmpeg could not read broken.mp4' || { echo "FAIL: broken mp4 refusal wording; got: $out"; exit 1; }
|
|
25
|
+
after=$(find "$sysTmp" -maxdepth 1 -name 'wpdl-*' 2>/dev/null | sort)
|
|
26
|
+
[ "$before" = "$after" ] || { echo "FAIL: temp wpdl- dir leaked; before=[$before] after=[$after]"; exit 1; }
|
|
27
|
+
[ ! -e "$tmp/entries/broken" ] || { echo "FAIL: refused add (broken.mp4) created entries/broken"; exit 1; }
|
|
28
|
+
# webm without a container duration (browser MediaRecorder style) is read from the video stream
|
|
29
|
+
VPX_ENC=""
|
|
30
|
+
if ffmpeg -encoders 2>/dev/null | grep -q ' libvpx '; then
|
|
31
|
+
VPX_ENC=libvpx
|
|
32
|
+
elif ffmpeg -encoders 2>/dev/null | grep -q ' libvpx-vp9 '; then
|
|
33
|
+
VPX_ENC=libvpx-vp9
|
|
34
|
+
fi
|
|
35
|
+
if [ -z "$VPX_ENC" ]; then
|
|
36
|
+
echo "SKIP: webm-without-duration case (no libvpx/libvpx-vp9 encoder in this ffmpeg build)"
|
|
37
|
+
echo " encoders seen: $(ffmpeg -encoders 2>/dev/null | grep -i vp || true)"
|
|
38
|
+
else
|
|
39
|
+
ffmpeg -y -loglevel error -framerate 10 -i "$tmp/f/%d.png" -c:v "$VPX_ENC" -f webm pipe:1 > "$tmp/piped.webm"
|
|
40
|
+
dur=$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 -i "$tmp/piped.webm")
|
|
41
|
+
[ "$dur" = "N/A" ] || { echo "FAIL: fixture webm unexpectedly has a container duration ($dur); no longer exercises the fallback"; exit 1; }
|
|
42
|
+
out=$(LIBRARY_DATA="$tmp" node bin/library.mjs add "$tmp/piped.webm" --slug webmclip --title WebmClip --source own --license "own work")
|
|
43
|
+
echo "$out" | grep -q '"frames":8' || { echo "FAIL: duration-less webm should still yield 8 frames (4s @ duration rule); got: $out"; exit 1; }
|
|
44
|
+
ms=$(grep -o 'duration_ms: [0-9]*' "$tmp/entries/webmclip/entry.md" | grep -o '[0-9]*')
|
|
45
|
+
[ -n "$ms" ] && [ "$ms" -ge 3950 ] && [ "$ms" -le 4050 ] || { echo "FAIL: media.duration_ms not ~4000; got: $ms"; exit 1; }
|
|
46
|
+
fi
|
|
47
|
+
echo PASS
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
5
|
+
mkdir -p "$tmp/entries"
|
|
6
|
+
out=$(LIBRARY_DATA="$tmp" node bin/library.mjs add tests/fixtures/three-frame.webp --slug fx --title "Fixture" --source open --license MIT)
|
|
7
|
+
echo "$out" | grep -q '"frames":3' || { echo "FAIL: frame count"; echo "$out"; exit 1; }
|
|
8
|
+
echo "$out" | grep -q '"blanks":\["roles","feel","motion.devices","motion.notes","type.display","type.body","body","palette.canvas","palette.ink","palette.accent"\]' || { echo "FAIL: blanks list"; echo "$out"; exit 1; }
|
|
9
|
+
[ -f "$tmp/entries/fx/strip.png" ] || { echo "FAIL: strip.png"; exit 1; }
|
|
10
|
+
grep -q '^roles: \[\]' "$tmp/entries/fx/entry.md" || { echo "FAIL: draft has empty roles"; exit 1; }
|
|
11
|
+
# add refuses to overwrite an existing draft without force, and exits 1
|
|
12
|
+
if LIBRARY_DATA="$tmp" node bin/library.mjs add tests/fixtures/three-frame.webp --slug fx --title "Fixture" --source open --license MIT >/dev/null 2>&1; then
|
|
13
|
+
echo "FAIL: refused add should exit 1"; exit 1
|
|
14
|
+
fi
|
|
15
|
+
out2=$(LIBRARY_DATA="$tmp" node bin/library.mjs add tests/fixtures/three-frame.webp --slug fx --title "Fixture" --source open --license MIT) || true
|
|
16
|
+
echo "$out2" | grep -q '"refused"' || { echo "FAIL: re-add without force did not refuse"; echo "$out2"; exit 1; }
|
|
17
|
+
# --force overwrites; this resets the draft, so it must happen before the fill step
|
|
18
|
+
out3=$(LIBRARY_DATA="$tmp" node bin/library.mjs add tests/fixtures/three-frame.webp --slug fx --title "Fixture" --source open --license MIT --force)
|
|
19
|
+
echo "$out3" | grep -q '"frames":3' || { echo "FAIL: force re-add did not succeed"; echo "$out3"; exit 1; }
|
|
20
|
+
# save refuses the unfilled draft
|
|
21
|
+
if LIBRARY_DATA="$tmp" node bin/library.mjs save fx >"$tmp/s" 2>&1; then echo "FAIL: saved a draft with blanks"; exit 1; fi
|
|
22
|
+
grep -q "roles: required, blank" "$tmp/s" || { echo "FAIL: refusal names the blank"; cat "$tmp/s"; exit 1; }
|
|
23
|
+
grep -q '"refused"' "$tmp/s" || { echo "FAIL: refusal missing refused field"; cat "$tmp/s"; exit 1; }
|
|
24
|
+
# fill it and save
|
|
25
|
+
python3 - "$tmp/entries/fx/entry.md" <<'EOF'
|
|
26
|
+
import io,sys
|
|
27
|
+
p=sys.argv[1]; s=io.open(p,encoding="utf-8").read()
|
|
28
|
+
s=s.replace("roles: []","roles: [hero]").replace("feel: []","feel: [dark]")
|
|
29
|
+
s=s.replace(" devices: []"," devices: [parallax]").replace(" notes: ''"," notes: bed lags the frame")
|
|
30
|
+
s=s.replace(" display: ''"," display: sans").replace(" body: ''"," body: sans")
|
|
31
|
+
s=s.replace(" canvas: ''"," canvas: '#0b0f14'").replace(" ink: ''"," ink: '#f4f6f8'").replace(" accent: ''"," accent: '#6ea8ff'")
|
|
32
|
+
s+="\n## What it does\n\nx\n\n## Section roster\n\nhero\n\n## Why it works\n\ny\n"
|
|
33
|
+
io.open(p,"w",encoding="utf-8").write(s)
|
|
34
|
+
EOF
|
|
35
|
+
LIBRARY_DATA="$tmp" node bin/library.mjs save fx | grep -q '"ok":true' || { echo "FAIL: save"; exit 1; }
|
|
36
|
+
LIBRARY_DATA="$tmp" node --input-type=module -e '
|
|
37
|
+
import { openIndex, getEntry } from "./src/index/query.mjs"; import { paths } from "./src/paths.mjs";
|
|
38
|
+
const db = openIndex(paths().index); if (!getEntry(db, "fx")) { console.error("FAIL: not reindexed"); process.exit(1); }'
|
|
39
|
+
echo PASS
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
node --input-type=module -e '
|
|
5
|
+
import { parseEntry, validateEntry } from "./src/entry.mjs";
|
|
6
|
+
import { loadVocab } from "./src/vocab.mjs";
|
|
7
|
+
const must = (c, m) => { if (!c) { console.error("FAIL: " + m); process.exit(1); } };
|
|
8
|
+
const v = loadVocab();
|
|
9
|
+
for (const kind of ["paid", "public-site"]) {
|
|
10
|
+
const e = parseEntry("tests/fixtures/entry-ok");
|
|
11
|
+
e.fm.tier = "ported"; e.fm.source.kind = kind; e.fm.ported_from = "entry-ok";
|
|
12
|
+
const r = validateEntry(e, v);
|
|
13
|
+
must(!r.ok && r.errors.some(x => x.includes("licence") && x.includes(kind)), "ported+" + kind + " refused");
|
|
14
|
+
}
|
|
15
|
+
for (const kind of ["own", "open"]) {
|
|
16
|
+
const e = parseEntry("tests/fixtures/entry-ok");
|
|
17
|
+
e.fm.tier = "ported"; e.fm.source.kind = kind; e.fm.ported_from = "entry-ok";
|
|
18
|
+
must(validateEntry(e, v).ok, "ported+" + kind + " allowed");
|
|
19
|
+
}
|
|
20
|
+
const e = parseEntry("tests/fixtures/entry-ok");
|
|
21
|
+
e.fm.tier = "ported"; e.fm.source.kind = "own";
|
|
22
|
+
must(!validateEntry(e, v).ok, "ported without ported_from refused");
|
|
23
|
+
'
|
|
24
|
+
echo PASS
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
tmp=$(mktemp -d); export LIBRARY_SEED="$tmp/noseed"; trap 'rm -rf "$tmp"' EXIT # LIBRARY_SEED isolates the check from the repo corpus
|
|
5
|
+
mkdir -p "$tmp/entries"; cp -r tests/fixtures/entry-ok "$tmp/entries/entry-ok"
|
|
6
|
+
{
|
|
7
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'
|
|
8
|
+
printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
|
9
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
|
|
10
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search","arguments":{"query":"parallax"}}}'
|
|
11
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"search","arguments":{"query":"","filters":{"role":["cta"]}}}}'
|
|
12
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"search","arguments":{"query":"","filters":{"role":["neon"]}}}}'
|
|
13
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"save_entry","arguments":{"slug":"../entry-ok"}}}'
|
|
14
|
+
sleep 1
|
|
15
|
+
} | LIBRARY_DATA="$tmp" timeout 10 node bin/library.mjs serve >"$tmp/out" 2>"$tmp/err" || true
|
|
16
|
+
# every stdout line must be JSON-RPC
|
|
17
|
+
while IFS= read -r line; do
|
|
18
|
+
[ -z "$line" ] && continue
|
|
19
|
+
printf '%s' "$line" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);if(j.jsonrpc!=="2.0")process.exit(1)})' \
|
|
20
|
+
|| { echo "FAIL: non-JSON-RPC on stdout: $line"; exit 1; }
|
|
21
|
+
done <"$tmp/out"
|
|
22
|
+
grep -q '"name":"search"' "$tmp/out" || { echo "FAIL: search not listed"; exit 1; }
|
|
23
|
+
grep -q '"name":"get_entry"' "$tmp/out" || { echo "FAIL: get_entry not listed"; exit 1; }
|
|
24
|
+
grep -q '"name":"add"' "$tmp/out" || { echo "FAIL: add not listed"; exit 1; }
|
|
25
|
+
grep -q '"name":"save_entry"' "$tmp/out" || { echo "FAIL: save_entry not listed"; exit 1; }
|
|
26
|
+
grep -q 'entry-ok' "$tmp/out" || { echo "FAIL: search hit"; exit 1; }
|
|
27
|
+
grep -q '"arms":\["fts"\]' "$tmp/out" || { echo "FAIL: arms reported"; exit 1; }
|
|
28
|
+
grep '"id":4' "$tmp/out" | grep -q 'entry-ok' || { echo "FAIL: role alias cta did not hit entry-ok"; exit 1; }
|
|
29
|
+
grep '"id":5' "$tmp/out" | grep -q '"unknown":\[{"facet":"role","term":"neon"}\]' || { echo "FAIL: unknown filter term not reported"; exit 1; }
|
|
30
|
+
grep '"id":6' "$tmp/out" | grep -q '"isError":true' || { echo "FAIL: save_entry did not reject a path-traversal slug"; exit 1; }
|
|
31
|
+
echo PASS
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -eu
|
|
3
|
+
cd "$(dirname "$0")/../.."
|
|
4
|
+
[ -n "${CHROME_PATH:-}" ] || CHROME_PATH=$(command -v google-chrome || command -v chromium || true)
|
|
5
|
+
[ -n "$CHROME_PATH" ] || { echo "SKIP: no chrome/chromium found"; exit 0; }
|
|
6
|
+
export CHROME_PATH
|
|
7
|
+
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
|
8
|
+
mkdir -p "$tmp/entries"
|
|
9
|
+
|
|
10
|
+
out=$(LIBRARY_DATA="$tmp" node bin/library.mjs add "file://$PWD/tests/fixtures/page/index.html" --slug fx --title Fx --source own --license x --record)
|
|
11
|
+
echo "$out" | grep -q '"blanks"' || { echo "FAIL: add did not draft"; echo "$out"; exit 1; }
|
|
12
|
+
# a measured add fills palette/type mechanically, so they must not come back in the
|
|
13
|
+
# blanks list a Claude session is told it still needs to fill; judgement fields still must.
|
|
14
|
+
echo "$out" | grep -q '"roles"' || { echo "FAIL: blanks missing roles"; echo "$out"; exit 1; }
|
|
15
|
+
if echo "$out" | grep -q '"palette.canvas"'; then
|
|
16
|
+
echo "FAIL: blanks still lists palette.canvas after a measured add"; echo "$out"; exit 1
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
mjson="$tmp/entries/fx/measure.json"
|
|
20
|
+
[ -f "$mjson" ] || { echo "FAIL: measure.json missing"; echo "$out"; exit 1; }
|
|
21
|
+
python3 - "$mjson" <<'EOF'
|
|
22
|
+
import json, os, sys
|
|
23
|
+
m = json.load(open(sys.argv[1]))
|
|
24
|
+
def fail(msg): print("FAIL: " + msg); sys.exit(1)
|
|
25
|
+
if m["tokens"]["canvas"] != "#0b1218": fail("canvas got " + str(m["tokens"]["canvas"]))
|
|
26
|
+
if m["tokens"]["ink"] != "#f2f5f8": fail("ink got " + str(m["tokens"]["ink"]))
|
|
27
|
+
if m["tokens"]["accent"] != "#7fb6ff": fail("accent got " + str(m["tokens"]["accent"]))
|
|
28
|
+
if "Georgia" not in m["tokens"]["fontDisplay"]: fail("fontDisplay got " + str(m["tokens"]["fontDisplay"]))
|
|
29
|
+
ok = any(a.get("timeline") == "view" and "entry" in (a.get("range") or "")
|
|
30
|
+
for s in m["sections"] for a in s.get("animations", []))
|
|
31
|
+
if not ok: fail("no section has a view-timeline animation with an entry range")
|
|
32
|
+
shot = os.path.join(os.path.dirname(sys.argv[1]), m["screenshots"]["1440"])
|
|
33
|
+
if not os.path.isfile(shot): fail("screenshots[1440] file missing: " + shot)
|
|
34
|
+
EOF
|
|
35
|
+
|
|
36
|
+
[ -f "$tmp/entries/fx/strip.png" ] || { echo "FAIL: strip.png missing"; exit 1; }
|
|
37
|
+
grep -q " method: both" "$tmp/entries/fx/entry.md" || { echo "FAIL: draft captured.method not both"; cat "$tmp/entries/fx/entry.md"; exit 1; }
|
|
38
|
+
grep -q "canvas: '#0b1218'" "$tmp/entries/fx/entry.md" || { echo "FAIL: draft palette.canvas not filled"; cat "$tmp/entries/fx/entry.md"; exit 1; }
|
|
39
|
+
|
|
40
|
+
# --record composites 12 stops: compositeStrip uses 3 columns under 12 tiles, so the
|
|
41
|
+
# strip must be 1440*3 wide and at least two single-screenshot heights tall — a strip
|
|
42
|
+
# that is just the lone 1440 screenshot (the non-record fallback) would fail both.
|
|
43
|
+
node --input-type=module -e "
|
|
44
|
+
import sharp from 'sharp';
|
|
45
|
+
const strip = await sharp('$tmp/entries/fx/strip.png').metadata();
|
|
46
|
+
const single = await sharp('$tmp/entries/fx/screen-1440.png').metadata();
|
|
47
|
+
if (strip.width !== 1440 * 3) { console.error('FAIL: 12-tile strip width ' + strip.width + ', expected ' + (1440 * 3)); process.exit(1); }
|
|
48
|
+
if (strip.height < single.height * 2) { console.error('FAIL: strip height ' + strip.height + ' not at least twice the single screenshot height ' + single.height); process.exit(1); }
|
|
49
|
+
"
|
|
50
|
+
|
|
51
|
+
# refresh re-measures and rewrites captured.at
|
|
52
|
+
out=$(LIBRARY_DATA="$tmp" node bin/library.mjs refresh fx)
|
|
53
|
+
echo "$out" | grep -q '"ok":true' || { echo "FAIL: refresh did not succeed"; echo "$out"; exit 1; }
|
|
54
|
+
today=$(date +%Y-%m-%d)
|
|
55
|
+
grep -Eq "^ at: '?$today'?\$" "$tmp/entries/fx/entry.md" || { echo "FAIL: captured.at not rewritten to today"; cat "$tmp/entries/fx/entry.md"; exit 1; }
|
|
56
|
+
|
|
57
|
+
# add without --record: strip is just the 1440 screenshot, method is measured (not both)
|
|
58
|
+
out=$(LIBRARY_DATA="$tmp" node bin/library.mjs add "file://$PWD/tests/fixtures/page/index.html" --slug fx-plain --title FxPlain --source own --license x)
|
|
59
|
+
echo "$out" | grep -q '"blanks"' || { echo "FAIL: add without --record did not draft"; echo "$out"; exit 1; }
|
|
60
|
+
grep -q " method: measured" "$tmp/entries/fx-plain/entry.md" || { echo "FAIL: draft captured.method not measured"; cat "$tmp/entries/fx-plain/entry.md"; exit 1; }
|
|
61
|
+
node --input-type=module -e "
|
|
62
|
+
import sharp from 'sharp';
|
|
63
|
+
const strip = await sharp('$tmp/entries/fx-plain/strip.png').metadata();
|
|
64
|
+
if (strip.width !== 1440) { console.error('FAIL: non-record strip width ' + strip.width + ', expected 1440'); process.exit(1); }
|
|
65
|
+
"
|
|
66
|
+
|
|
67
|
+
# chrome unavailable: add refuses, exits 1, nothing left under entries/
|
|
68
|
+
if out=$(CHROME_PATH=/nonexistent LIBRARY_DATA="$tmp" node bin/library.mjs add "file://$PWD/tests/fixtures/page/index.html" --slug fx2 --title Fx2 --source own --license x 2>&1); then
|
|
69
|
+
echo "FAIL: add with an unavailable chrome should refuse and exit non-zero"; echo "$out"; exit 1
|
|
70
|
+
fi
|
|
71
|
+
echo "$out" | grep -q '"refused"' || { echo "FAIL: refusal missing refused field"; echo "$out"; exit 1; }
|
|
72
|
+
echo "$out" | grep -qi chrome || { echo "FAIL: refusal does not mention chrome"; echo "$out"; exit 1; }
|
|
73
|
+
[ -d "$tmp/entries/fx2" ] && { echo "FAIL: refused add left a directory under entries/"; exit 1; }
|
|
74
|
+
|
|
75
|
+
echo PASS
|