paperlint 13.2.0 → 13.3.1
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/.github/workflows/ci.yml +43 -1
- package/CLAUDE.md +3 -3
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +2 -2
- package/dist/doctor.js.map +1 -1
- package/dist/skills/assess-novelty/scripts/candidate_page.d.ts +1 -0
- package/dist/skills/assess-novelty/scripts/novelty_reader_brief.d.ts +25 -0
- package/docs/testing.md +5 -2
- package/docs/toolchain.md +1 -1
- package/docs/website.md +10 -0
- package/eslint.config.mjs +52 -0
- package/package.json +2 -1
- package/presets/agentverify.md +15 -4
- package/scripts/check.ts +4 -1
- package/skills/assess-novelty/scripts/candidate_page.css +31 -0
- package/skills/assess-novelty/scripts/candidate_page.html +934 -0
- package/skills/assess-novelty/scripts/candidate_page.js +165 -0
- package/skills/assess-novelty/scripts/candidate_page.test.ts +401 -0
- package/skills/assess-novelty/scripts/candidate_page.ts +194 -0
- package/skills/assess-novelty/scripts/check_novelty.py +275 -0
- package/skills/assess-novelty/scripts/check_novelty.test.ts +624 -0
- package/skills/assess-novelty/scripts/check_novelty_coverage.test.ts +176 -0
- package/skills/assess-novelty/scripts/crop_quote.py +104 -0
- package/skills/assess-novelty/scripts/crop_quote.test.ts +130 -0
- package/skills/assess-novelty/scripts/fixtures/crop-quote/make_sample.py +42 -0
- package/skills/assess-novelty/scripts/fixtures/crop-quote/sample.pdf +106 -0
- package/skills/assess-novelty/scripts/fixtures/novelty/CLAIMS.md +11 -0
- package/skills/assess-novelty/scripts/fixtures/novelty/paper.tex +10 -0
- package/skills/assess-novelty/scripts/fixtures/novelty/siblings/2026-10-27-novelty-refutation.md +8 -0
- package/skills/assess-novelty/scripts/fixtures/novelty/siblings/data/doe-preprint.txt +63 -0
- package/skills/assess-novelty/scripts/fixtures/novelty/siblings/novelty.json +54 -0
- package/skills/assess-novelty/scripts/novelty_candidates.py +136 -0
- package/skills/assess-novelty/scripts/novelty_candidates.test.ts +222 -0
- package/skills/assess-novelty/scripts/novelty_cells.py +88 -0
- package/skills/assess-novelty/scripts/novelty_cells.test.ts +159 -0
- package/skills/assess-novelty/scripts/novelty_coverage.py +55 -0
- package/skills/assess-novelty/scripts/novelty_reader_brief.js +165 -0
- package/skills/assess-novelty/scripts/novelty_reader_brief.test.ts +251 -0
- package/skills/assess-novelty/scripts/novelty_reader_brief.ts +215 -0
- package/skills/assess-novelty/scripts/quote_match.py +34 -0
- package/skills/assess-novelty/scripts/script_edges.test.ts +174 -0
- package/src/doctor.ts +4 -2
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The author's decision page for a paper's candidate neighbours, and the decisions back into the repo.
|
|
3
|
+
*
|
|
4
|
+
* node candidate_page.ts build <paper-dir> <out.html>
|
|
5
|
+
* node candidate_page.ts export <paper-dir> <decisions-dir>
|
|
6
|
+
*
|
|
7
|
+
* build: reads siblings/candidates/*.json (shape and rules: novelty_candidates.py, asked through its `shape`
|
|
8
|
+
* command so there is one copy of them), the claims of siblings/novelty.json and siblings/candidate-page.json:
|
|
9
|
+
*
|
|
10
|
+
* {"artifact": "https://claude.ai/artifact/...", // where the page is published, one URL per paper
|
|
11
|
+
* "session": "session_...", // the Claude session "Send decisions to Claude" wakes
|
|
12
|
+
* "pdfs": {"<candidate id>": "<asset url of its PDF>"}}
|
|
13
|
+
*
|
|
14
|
+
* and fills candidate_page.html: an overview tab and one tab per candidate (in plain words, the authors'
|
|
15
|
+
* abstract and conclusion, why it came up, each passage as its highlighted page crop with the verbatim quote
|
|
16
|
+
* and what it means, PDF and source links, add / cite / skip with a reason). Crops are embedded and the look is
|
|
17
|
+
* candidate_page.css inlined, so the page rebuilds from the repo alone. Publish it with
|
|
18
|
+
* capabilities {db: {}, assets: {}, mcp: {servers: [{server: "Claude Code Remote", tools: ["send_message"]}]}}.
|
|
19
|
+
* A press saves to the `decisions` collection (doc id = candidate id: {choice, reason, at, source: "page"}).
|
|
20
|
+
*
|
|
21
|
+
* export: reads the `decisions` documents ArtifactData saved (`list` with out_dir; one <id>.json each) and
|
|
22
|
+
* writes each into its candidate file's `decision`. An id with no candidate or a choice outside add / cite /
|
|
23
|
+
* skip is refused and nothing is written; a file whose decision is unchanged is left alone.
|
|
24
|
+
*/
|
|
25
|
+
import { execFileSync } from "node:child_process";
|
|
26
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { basename, dirname, join, normalize } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const TEMPLATE = join(here, "candidate_page.html");
|
|
31
|
+
const OCEAN = join(here, "candidate_page.css");
|
|
32
|
+
const CHOICES = ["add", "cite", "skip"];
|
|
33
|
+
const DOC_FIELDS = ["choice", "reason", "at", "source"];
|
|
34
|
+
const fail = (msg) => {
|
|
35
|
+
console.error(`ERROR ${msg}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
};
|
|
38
|
+
const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
|
|
39
|
+
const candidateFiles = (paper) => {
|
|
40
|
+
const dir = join(paper, "siblings", "candidates");
|
|
41
|
+
return existsSync(dir)
|
|
42
|
+
? readdirSync(dir)
|
|
43
|
+
.filter((n) => n.endsWith(".json"))
|
|
44
|
+
.sort()
|
|
45
|
+
.map((n) => join(dir, n))
|
|
46
|
+
: [];
|
|
47
|
+
};
|
|
48
|
+
const shapeProblems = (paper) => {
|
|
49
|
+
try {
|
|
50
|
+
execFileSync("python3", ["-I", join(here, "novelty_candidates.py"), "shape", paper], { encoding: "utf8" });
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
return (e.stdout || e.message).trim();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const pageData = (paper) => {
|
|
58
|
+
const problems = shapeProblems(paper);
|
|
59
|
+
if (problems)
|
|
60
|
+
fail(problems.split("\n").join("; "));
|
|
61
|
+
const files = candidateFiles(paper);
|
|
62
|
+
if (!files.length)
|
|
63
|
+
fail(`no candidates in ${paper}/siblings/candidates`);
|
|
64
|
+
const conf = readJson(join(paper, "siblings", "candidate-page.json"));
|
|
65
|
+
const claims = Object.fromEntries((readJson(join(paper, "siblings", "novelty.json")).claims || []).map((c) => [c.id, c.sentence || ""]));
|
|
66
|
+
const crop = (rel) => `data:image/png;base64,${readFileSync(join(paper, rel)).toString("base64")}`;
|
|
67
|
+
return {
|
|
68
|
+
paper: basename(normalize(paper)),
|
|
69
|
+
session: conf.session || "",
|
|
70
|
+
claims,
|
|
71
|
+
candidates: files.map((f) => {
|
|
72
|
+
const c = readJson(f);
|
|
73
|
+
return {
|
|
74
|
+
id: c.id,
|
|
75
|
+
title: c.title,
|
|
76
|
+
authors: c.authors,
|
|
77
|
+
venue: c.venue,
|
|
78
|
+
year: c.year,
|
|
79
|
+
url: c.url,
|
|
80
|
+
summary: c.summary,
|
|
81
|
+
abstract: c.abstract,
|
|
82
|
+
conclusion: c.conclusion,
|
|
83
|
+
suggestion: c.suggestion,
|
|
84
|
+
suggestion_why: c.suggestion_why,
|
|
85
|
+
found_by: c.found_by,
|
|
86
|
+
pdf: (conf.pdfs || {})[c.id] || null,
|
|
87
|
+
decision: c.decision || null,
|
|
88
|
+
passages: c.passages.map((p) => ({
|
|
89
|
+
claim: p.claim,
|
|
90
|
+
page: p.page,
|
|
91
|
+
quote: p.quote,
|
|
92
|
+
meaning: p.meaning,
|
|
93
|
+
img: crop(p.crop),
|
|
94
|
+
})),
|
|
95
|
+
};
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
const build = (paper, out) => {
|
|
100
|
+
const data = pageData(paper);
|
|
101
|
+
// </script> inside the JSON would end the data block early; "<\/" is the same string to JSON.parse.
|
|
102
|
+
const blob = JSON.stringify(data).replaceAll("</", "<\\/");
|
|
103
|
+
const title = `Neighbour Candidates · ${data.paper}`.replace(/[&<>"]/g, (ch) => `&#${ch.charCodeAt(0)};`);
|
|
104
|
+
const page = readFileSync(TEMPLATE, "utf8")
|
|
105
|
+
.replace("__TITLE__", () => title)
|
|
106
|
+
.replace("__OCEAN__", () => readFileSync(OCEAN, "utf8"))
|
|
107
|
+
.replace("__DATA__", () => blob);
|
|
108
|
+
writeFileSync(out, page);
|
|
109
|
+
console.log(`${out}: ${data.candidates.length} candidate(s), ${Math.floor(page.length / 1024)} KB`);
|
|
110
|
+
};
|
|
111
|
+
const exportDecisions = (paper, folder) => {
|
|
112
|
+
const cands = new Map(candidateFiles(paper).map((f) => [basename(f, ".json"), f]));
|
|
113
|
+
const sub = existsSync(join(folder, "decisions"))
|
|
114
|
+
? join(folder, "decisions")
|
|
115
|
+
: folder;
|
|
116
|
+
// ArtifactData may wrap a document's fields; take the fields either way.
|
|
117
|
+
const docs = readdirSync(sub)
|
|
118
|
+
.filter((n) => n.endsWith(".json"))
|
|
119
|
+
.sort()
|
|
120
|
+
.map((n) => {
|
|
121
|
+
const raw = readJson(join(sub, n));
|
|
122
|
+
return [
|
|
123
|
+
basename(n, ".json"),
|
|
124
|
+
raw.data && typeof raw.data === "object" ? raw.data : raw,
|
|
125
|
+
];
|
|
126
|
+
});
|
|
127
|
+
const problems = [
|
|
128
|
+
...docs
|
|
129
|
+
.filter(([id]) => !cands.has(id))
|
|
130
|
+
.map(([id]) => `${id}: no candidate with this id`),
|
|
131
|
+
...docs
|
|
132
|
+
.filter(([, d]) => !CHOICES.includes(d.choice))
|
|
133
|
+
.map(([id, d]) => `${id}: choice ${JSON.stringify(d.choice)} is not one of ${CHOICES.join(", ")}`),
|
|
134
|
+
];
|
|
135
|
+
if (problems.length)
|
|
136
|
+
fail(problems.join("; "));
|
|
137
|
+
docs.forEach(([id, d]) => {
|
|
138
|
+
const file = cands.get(id);
|
|
139
|
+
const c = readJson(file);
|
|
140
|
+
const decision = {
|
|
141
|
+
source: "page",
|
|
142
|
+
...Object.fromEntries(DOC_FIELDS.filter((k) => d[k] !== undefined && d[k] !== null && d[k] !== "").map((k) => [k, d[k]])),
|
|
143
|
+
};
|
|
144
|
+
const sorted = (o) => JSON.stringify(Object.entries(o ?? {}).sort());
|
|
145
|
+
if (sorted(c.decision) === sorted(decision))
|
|
146
|
+
return console.log(`${id}: unchanged (${decision.choice})`);
|
|
147
|
+
writeFileSync(file, JSON.stringify({ ...c, decision }, null, 2) + "\n");
|
|
148
|
+
console.log(`${id}: ${decision.choice}${decision.reason ? ` — ${decision.reason}` : ""}`);
|
|
149
|
+
});
|
|
150
|
+
const decided = new Set(docs.map(([id]) => id));
|
|
151
|
+
const waiting = [...cands]
|
|
152
|
+
.filter(([id, f]) => !decided.has(id) && !readJson(f).decision)
|
|
153
|
+
.map(([id]) => id);
|
|
154
|
+
if (waiting.length)
|
|
155
|
+
console.log(`still undecided: ${waiting.join(", ")}`);
|
|
156
|
+
};
|
|
157
|
+
const [cmd, paper, target] = process.argv.slice(2);
|
|
158
|
+
if (!cmd || !paper || !target || !["build", "export"].includes(cmd)) {
|
|
159
|
+
console.error("usage: node candidate_page.ts build <paper-dir> <out.html> | export <paper-dir> <decisions-dir>");
|
|
160
|
+
process.exit(2);
|
|
161
|
+
}
|
|
162
|
+
if (cmd === "build")
|
|
163
|
+
build(paper, target);
|
|
164
|
+
else
|
|
165
|
+
exportDecisions(paper, target);
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* candidate_page.ts: the author's add / cite / skip page for candidate neighbours, and its decisions back into
|
|
3
|
+
* the candidate files. Execution tier: the built page in the pre-installed Chromium, with claude.ai's `db` and
|
|
4
|
+
* `mcp` replaced by stand-ins that record calls; no network, no model.
|
|
5
|
+
* Run: npx vitest run skills/assess-novelty/scripts/candidate_page.test.ts
|
|
6
|
+
*
|
|
7
|
+
* What this does not reach: the claude.ai viewer itself (its frame, its consent prompts, the real store and the
|
|
8
|
+
* real Claude Code Remote connector). Those are exercised once by hand after each publish.
|
|
9
|
+
*/
|
|
10
|
+
/// <reference lib="dom" />
|
|
11
|
+
import {
|
|
12
|
+
cpSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
mkdtempSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
realpathSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import assert from "node:assert/strict";
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const TOOL = join(here, "candidate_page.ts");
|
|
28
|
+
const FIXTURE = join(here, "fixtures", "novelty");
|
|
29
|
+
// playwright-core is what package.json declares; the browser is whichever chromiumPath() finds (CHROMIUM_PATH, a
|
|
30
|
+
// build in PLAYWRIGHT_BROWSERS_PATH, or the Chrome a GitHub runner ships), so nothing is downloaded.
|
|
31
|
+
import { chromium } from "playwright-core";
|
|
32
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
33
|
+
import { test } from "vitest";
|
|
34
|
+
|
|
35
|
+
test("candidate_page", async () => {
|
|
36
|
+
const BROWSERS_DIR =
|
|
37
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH || "/opt/pw-browsers";
|
|
38
|
+
const SYSTEM_CHROME = [
|
|
39
|
+
"/usr/bin/google-chrome",
|
|
40
|
+
"/usr/bin/google-chrome-stable",
|
|
41
|
+
"/usr/bin/chromium",
|
|
42
|
+
"/usr/bin/chromium-browser",
|
|
43
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", // the macOS runner's Chrome
|
|
44
|
+
];
|
|
45
|
+
const chromiumPath = (): string => {
|
|
46
|
+
const builds = existsSync(BROWSERS_DIR)
|
|
47
|
+
? readdirSync(BROWSERS_DIR)
|
|
48
|
+
.filter((d) => /^chromium-\d+$/.test(d))
|
|
49
|
+
.sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]))
|
|
50
|
+
.flatMap((d) =>
|
|
51
|
+
["chrome-linux64", "chrome-linux"].map((sub) =>
|
|
52
|
+
join(BROWSERS_DIR, d, sub, "chrome"),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
: [];
|
|
56
|
+
const found = [process.env.CHROMIUM_PATH, ...builds, ...SYSTEM_CHROME].find(
|
|
57
|
+
(p) => p && existsSync(p),
|
|
58
|
+
);
|
|
59
|
+
if (!found)
|
|
60
|
+
throw new Error(
|
|
61
|
+
`no Chromium: set CHROMIUM_PATH, or install one in ${BROWSERS_DIR} or ${SYSTEM_CHROME[0]}`,
|
|
62
|
+
);
|
|
63
|
+
return found;
|
|
64
|
+
};
|
|
65
|
+
// A 1x1 PNG, enough for an <img> to load.
|
|
66
|
+
const PNG = Buffer.from(
|
|
67
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
68
|
+
"base64",
|
|
69
|
+
);
|
|
70
|
+
const SENTENCE =
|
|
71
|
+
"We ran Checkstyle before and after applying the initial patch and the final patch.";
|
|
72
|
+
const ABSTRACT =
|
|
73
|
+
"We study whether code review removes the coding convention violations a linter reports.";
|
|
74
|
+
const CONCLUSION = [
|
|
75
|
+
"Review removes some violations, and most of the rest stay in the code after the merge.",
|
|
76
|
+
"Tool builders may want to revisit which checks are on by default for code review.",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
const candidate = {
|
|
80
|
+
id: "roe20",
|
|
81
|
+
title: "Does code review really remove style violations?",
|
|
82
|
+
authors: "Roe, Poe",
|
|
83
|
+
venue: "SomeConf",
|
|
84
|
+
year: 2020,
|
|
85
|
+
url: "https://example.org/roe20",
|
|
86
|
+
text: "siblings/data/roe20.txt",
|
|
87
|
+
found_by: [
|
|
88
|
+
{ claim: "P5-1", file: "siblings/2026-10-29-novelty-refutation-P5-1.md" },
|
|
89
|
+
],
|
|
90
|
+
summary: "Runs a linter on review patches before and after review.",
|
|
91
|
+
abstract: { page: 1, text: ABSTRACT },
|
|
92
|
+
conclusion: { page: 9, text: CONCLUSION.join("\n\n") },
|
|
93
|
+
passages: [
|
|
94
|
+
{
|
|
95
|
+
claim: "P5-1",
|
|
96
|
+
page: 4,
|
|
97
|
+
quote: SENTENCE,
|
|
98
|
+
crop: "siblings/data/crops/roe20-1.png",
|
|
99
|
+
meaning: "The before/after linter run is an established instrument.",
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
suggestion: "cite",
|
|
103
|
+
suggestion_why: "Same instrument, not the same object.",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const paperDir = () => {
|
|
107
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "cand-page-")));
|
|
108
|
+
cpSync(FIXTURE, dir, { recursive: true });
|
|
109
|
+
for (const d of ["candidates", "data/crops"])
|
|
110
|
+
mkdirSync(join(dir, "siblings", d), { recursive: true });
|
|
111
|
+
writeFileSync(
|
|
112
|
+
join(dir, "siblings", "candidates", "roe20.json"),
|
|
113
|
+
JSON.stringify(candidate, null, 2) + "\n",
|
|
114
|
+
);
|
|
115
|
+
writeFileSync(
|
|
116
|
+
join(dir, "siblings", "data", "roe20.txt"),
|
|
117
|
+
[ABSTRACT, SENTENCE, ...CONCLUSION, ""].join("\n"),
|
|
118
|
+
);
|
|
119
|
+
writeFileSync(join(dir, "siblings", "data", "crops", "roe20-1.png"), PNG);
|
|
120
|
+
writeFileSync(
|
|
121
|
+
join(dir, "siblings", "candidate-page.json"),
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
artifact: "https://claude.ai/artifact/x",
|
|
124
|
+
session: "session_test",
|
|
125
|
+
pdfs: { roe20: "https://example.org/roe20.pdf" },
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
return dir;
|
|
129
|
+
};
|
|
130
|
+
const py = (...args: string[]) =>
|
|
131
|
+
spawnSync(process.execPath, [TOOL, ...args], { encoding: "utf8" });
|
|
132
|
+
|
|
133
|
+
// The stand-ins claude.ai would provide. `withClaude: false` is a copy of the page opened outside the viewer.
|
|
134
|
+
const STUB = `
|
|
135
|
+
window.__writes = []; window.__sent = [];
|
|
136
|
+
var listeners = [], docs = {};
|
|
137
|
+
var snap = function () { return { docs: Object.keys(docs).map(function (k) { return { id: k, data: function () { return docs[k]; } }; }) }; };
|
|
138
|
+
var db = { collection: function (c) { return {
|
|
139
|
+
onSnapshot: function (ok) { listeners.push(ok); setTimeout(function () { ok(snap()); }, 0); return function () {}; },
|
|
140
|
+
doc: function (id) { return { set: function (body) { window.__writes.push({ c: c, id: id, body: body });
|
|
141
|
+
docs[id] = body; listeners.forEach(function (f) { f(snap()); });
|
|
142
|
+
// window.__hold keeps a save open until the test calls window.__release(), as a slow store would.
|
|
143
|
+
return window.__hold ? new Promise(function (ok) { window.__release = ok; }) : Promise.resolve(); } }; } }; } };
|
|
144
|
+
var mcp = { callTool: function (s, t, input) { window.__sent.push({ s: s, t: t, input: input }); return Promise.resolve({ content: [] }); } };
|
|
145
|
+
window.claude = { use: function (n) { return Promise.resolve(n === "db" ? db : n === "mcp" ? mcp : null); } };`;
|
|
146
|
+
|
|
147
|
+
const browse = async (
|
|
148
|
+
html: string,
|
|
149
|
+
withClaude: boolean,
|
|
150
|
+
steps: (page: any) => Promise<void>,
|
|
151
|
+
) => {
|
|
152
|
+
const browser = await chromium.launch({
|
|
153
|
+
executablePath: chromiumPath(),
|
|
154
|
+
args: ["--no-sandbox"],
|
|
155
|
+
});
|
|
156
|
+
try {
|
|
157
|
+
const page = await browser.newPage();
|
|
158
|
+
await page.route("**/*", (r: any) =>
|
|
159
|
+
r.request().url().startsWith("data:") ? r.continue() : r.abort(),
|
|
160
|
+
);
|
|
161
|
+
if (withClaude) await page.addInitScript(STUB);
|
|
162
|
+
await page.goto("about:blank");
|
|
163
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
164
|
+
await steps(page);
|
|
165
|
+
} finally {
|
|
166
|
+
await browser.close();
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const dir = paperDir();
|
|
171
|
+
try {
|
|
172
|
+
// ── build: refuses a candidate whose crop is missing, then builds one tab per candidate ──
|
|
173
|
+
const out = join(dir, "page.html");
|
|
174
|
+
rmSync(join(dir, "siblings", "data", "crops", "roe20-1.png"));
|
|
175
|
+
const missing = py("build", dir, out);
|
|
176
|
+
assert.equal(missing.status, 1);
|
|
177
|
+
assert.match(
|
|
178
|
+
missing.stderr,
|
|
179
|
+
/crop siblings\/data\/crops\/roe20-1.png does not exist/,
|
|
180
|
+
);
|
|
181
|
+
writeFileSync(join(dir, "siblings", "data", "crops", "roe20-1.png"), PNG);
|
|
182
|
+
const built = py("build", dir, out);
|
|
183
|
+
assert.equal(built.status, 0, built.stderr);
|
|
184
|
+
const html = readFileSync(out, "utf8");
|
|
185
|
+
assert.match(html, /data:image\/png;base64,/, "the crop is embedded");
|
|
186
|
+
|
|
187
|
+
// ── the page: tabs, saving on press, skip needs a reason, send names every decision ──
|
|
188
|
+
await browse(html, true, async (page) => {
|
|
189
|
+
await page.waitForFunction(
|
|
190
|
+
() =>
|
|
191
|
+
document.getElementById("progress")?.textContent === "0 of 1 decided",
|
|
192
|
+
);
|
|
193
|
+
const tabs = await page.$$eval(".tab", (b: any[]) =>
|
|
194
|
+
b.map((x) => x.textContent),
|
|
195
|
+
);
|
|
196
|
+
assert.deepEqual(tabs, ["Overview", "Roe 2020"]);
|
|
197
|
+
await page.click("text=Roe 2020");
|
|
198
|
+
await page.waitForSelector("#choice-roe20-skip:not([disabled])");
|
|
199
|
+
assert.equal(
|
|
200
|
+
await page.getAttribute("a:has-text('Open the PDF')", "href"),
|
|
201
|
+
"https://example.org/roe20.pdf",
|
|
202
|
+
);
|
|
203
|
+
assert.match(
|
|
204
|
+
(await page.textContent("blockquote")) as string,
|
|
205
|
+
/We ran Checkstyle/,
|
|
206
|
+
);
|
|
207
|
+
assert.equal(
|
|
208
|
+
await page
|
|
209
|
+
.getAttribute(".passage img", "src")
|
|
210
|
+
.then((s: string) => s.slice(0, 22)),
|
|
211
|
+
"data:image/png;base64,",
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// The authors' own abstract and conclusion come first, each with its page; a long conclusion folds after
|
|
215
|
+
// its first paragraph.
|
|
216
|
+
assert.equal(
|
|
217
|
+
await page.textContent("#own-roe20-abstract figcaption"),
|
|
218
|
+
"Abstract · PDF p. 1",
|
|
219
|
+
);
|
|
220
|
+
assert.equal(await page.textContent("#own-roe20-abstract > p"), ABSTRACT);
|
|
221
|
+
assert.equal(
|
|
222
|
+
await page.textContent("#own-roe20-conclusion figcaption"),
|
|
223
|
+
"Conclusion · PDF p. 9",
|
|
224
|
+
);
|
|
225
|
+
assert.equal(
|
|
226
|
+
await page.textContent("#own-roe20-conclusion > p"),
|
|
227
|
+
CONCLUSION[0],
|
|
228
|
+
);
|
|
229
|
+
assert.equal(
|
|
230
|
+
await page.isVisible(
|
|
231
|
+
`#own-roe20-conclusion details p:has-text("${CONCLUSION[1]}")`,
|
|
232
|
+
),
|
|
233
|
+
false,
|
|
234
|
+
);
|
|
235
|
+
await page.click("#own-roe20-conclusion summary");
|
|
236
|
+
assert.equal(
|
|
237
|
+
await page.isVisible(
|
|
238
|
+
`#own-roe20-conclusion details p:has-text("${CONCLUSION[1]}")`,
|
|
239
|
+
),
|
|
240
|
+
true,
|
|
241
|
+
);
|
|
242
|
+
assert.equal(
|
|
243
|
+
await page.evaluate(() => {
|
|
244
|
+
const own = document.getElementById("own-roe20-abstract"),
|
|
245
|
+
fig = document.querySelector(".passage");
|
|
246
|
+
return (
|
|
247
|
+
!!own &&
|
|
248
|
+
!!fig &&
|
|
249
|
+
!!(
|
|
250
|
+
own.compareDocumentPosition(fig) &
|
|
251
|
+
Node.DOCUMENT_POSITION_FOLLOWING
|
|
252
|
+
)
|
|
253
|
+
);
|
|
254
|
+
}),
|
|
255
|
+
true,
|
|
256
|
+
"the authors' words sit above the passages",
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
await page.click("#choice-roe20-skip");
|
|
260
|
+
assert.match(
|
|
261
|
+
(await page.textContent(".status.warn")) as string,
|
|
262
|
+
/Skip needs a reason/,
|
|
263
|
+
);
|
|
264
|
+
assert.deepEqual(
|
|
265
|
+
await page.evaluate(() => (window as any).__writes),
|
|
266
|
+
[],
|
|
267
|
+
"an empty-reason skip saves nothing",
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
await page.fill("#reason-roe20", "the instrument only");
|
|
271
|
+
await page.click("#choice-roe20-cite");
|
|
272
|
+
await page.waitForFunction(
|
|
273
|
+
() =>
|
|
274
|
+
document.getElementById("progress")?.textContent === "1 of 1 decided",
|
|
275
|
+
);
|
|
276
|
+
const writes = await page.evaluate(() => (window as any).__writes);
|
|
277
|
+
assert.equal(writes.length, 1);
|
|
278
|
+
assert.equal(writes[0].c, "decisions");
|
|
279
|
+
assert.equal(writes[0].id, "roe20");
|
|
280
|
+
assert.deepEqual(
|
|
281
|
+
{ ...writes[0].body, at: "t" },
|
|
282
|
+
{
|
|
283
|
+
choice: "cite",
|
|
284
|
+
reason: "the instrument only",
|
|
285
|
+
at: "t",
|
|
286
|
+
source: "page",
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
assert.equal(
|
|
290
|
+
await page.getAttribute("#choice-roe20-cite", "aria-pressed"),
|
|
291
|
+
"true",
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
await page.click("#send");
|
|
295
|
+
await page.waitForFunction(() =>
|
|
296
|
+
/Sent to Claude/.test(
|
|
297
|
+
document.getElementById("sendstatus")?.textContent || "",
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
const sent = await page.evaluate(() => (window as any).__sent);
|
|
301
|
+
assert.equal(sent.length, 1);
|
|
302
|
+
assert.equal(sent[0].s, "Claude Code Remote");
|
|
303
|
+
assert.equal(sent[0].t, "send_message");
|
|
304
|
+
assert.equal(sent[0].input.session_id, "session_test");
|
|
305
|
+
assert.match(
|
|
306
|
+
sent[0].input.message,
|
|
307
|
+
/- roe20: cite \(the instrument only\)/,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// A reason typed while a save is still in flight survives the save's success.
|
|
311
|
+
await page.evaluate(() => {
|
|
312
|
+
(window as any).__hold = true;
|
|
313
|
+
});
|
|
314
|
+
await page.fill("#reason-roe20", "first reason");
|
|
315
|
+
await page.click("#choice-roe20-add");
|
|
316
|
+
await page.waitForSelector("#choice-roe20-add[disabled]");
|
|
317
|
+
// Send waits for the write: sent now, it would hand Claude the previous choice.
|
|
318
|
+
assert.equal(
|
|
319
|
+
await page.isDisabled("#send"),
|
|
320
|
+
true,
|
|
321
|
+
"Send is off while a decision is saving",
|
|
322
|
+
);
|
|
323
|
+
await page.fill("#reason-roe20", "first reason, then a second thought");
|
|
324
|
+
await page.evaluate(() => (window as any).__release());
|
|
325
|
+
await page.waitForSelector("#choice-roe20-add:not([disabled])");
|
|
326
|
+
assert.equal(
|
|
327
|
+
await page.inputValue("#reason-roe20"),
|
|
328
|
+
"first reason, then a second thought",
|
|
329
|
+
);
|
|
330
|
+
assert.equal(
|
|
331
|
+
await page.isDisabled("#send"),
|
|
332
|
+
false,
|
|
333
|
+
"Send is back once the write succeeded",
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ── outside the viewer: read-only, said so, no send button ──
|
|
338
|
+
await browse(html, false, async (page) => {
|
|
339
|
+
await page.click("text=Roe 2020");
|
|
340
|
+
assert.match(
|
|
341
|
+
(await page.textContent(".banner")) as string,
|
|
342
|
+
/cannot be saved in this view/,
|
|
343
|
+
);
|
|
344
|
+
assert.equal(await page.isDisabled("#choice-roe20-add"), true);
|
|
345
|
+
assert.equal(await page.isHidden("#send"), true);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ── export: decisions into the candidate file; an unknown id or choice writes nothing ──
|
|
349
|
+
const dump = join(dir, "dump", "decisions");
|
|
350
|
+
mkdirSync(dump, { recursive: true });
|
|
351
|
+
const file = join(dir, "siblings", "candidates", "roe20.json");
|
|
352
|
+
const before = readFileSync(file, "utf8");
|
|
353
|
+
writeFileSync(
|
|
354
|
+
join(dump, "nobody99.json"),
|
|
355
|
+
JSON.stringify({ choice: "add", at: "2026-10-29T17:00:00Z" }),
|
|
356
|
+
);
|
|
357
|
+
writeFileSync(
|
|
358
|
+
join(dump, "roe20.json"),
|
|
359
|
+
JSON.stringify({
|
|
360
|
+
choice: "cite",
|
|
361
|
+
reason: "the instrument only",
|
|
362
|
+
at: "2026-10-29T17:00:00Z",
|
|
363
|
+
source: "page",
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
const refused = py("export", dir, join(dir, "dump"));
|
|
367
|
+
assert.equal(refused.status, 1);
|
|
368
|
+
assert.match(refused.stderr, /nobody99: no candidate with this id/);
|
|
369
|
+
assert.equal(
|
|
370
|
+
readFileSync(file, "utf8"),
|
|
371
|
+
before,
|
|
372
|
+
"a refused export writes nothing",
|
|
373
|
+
);
|
|
374
|
+
rmSync(join(dump, "nobody99.json"));
|
|
375
|
+
const ok = py("export", dir, join(dir, "dump"));
|
|
376
|
+
assert.equal(ok.status, 0, ok.stderr);
|
|
377
|
+
assert.deepEqual(JSON.parse(readFileSync(file, "utf8")).decision, {
|
|
378
|
+
choice: "cite",
|
|
379
|
+
reason: "the instrument only",
|
|
380
|
+
at: "2026-10-29T17:00:00Z",
|
|
381
|
+
source: "page",
|
|
382
|
+
});
|
|
383
|
+
assert.match(
|
|
384
|
+
py("export", dir, join(dir, "dump")).stdout,
|
|
385
|
+
/roe20: unchanged \(cite\)/,
|
|
386
|
+
);
|
|
387
|
+
// The exported decision settles the candidate in the checker too.
|
|
388
|
+
const check = spawnSync(
|
|
389
|
+
"python3",
|
|
390
|
+
["-I", join(here, "check_novelty.py"), dir],
|
|
391
|
+
{ encoding: "utf8" },
|
|
392
|
+
);
|
|
393
|
+
assert.doesNotMatch(
|
|
394
|
+
check.stdout + check.stderr,
|
|
395
|
+
/roe20/,
|
|
396
|
+
check.stdout + check.stderr,
|
|
397
|
+
);
|
|
398
|
+
} finally {
|
|
399
|
+
rmSync(dir, { recursive: true, force: true });
|
|
400
|
+
}
|
|
401
|
+
}, 300_000);
|