paperlint 10.4.0 → 11.0.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.
Files changed (232) hide show
  1. package/.github/workflows/deadlines.yml +27 -7
  2. package/README.md +16 -15
  3. package/dist/CLAUDE.md.spec.d.ts.map +1 -1
  4. package/dist/CLAUDE.md.spec.js +6 -3
  5. package/dist/CLAUDE.md.spec.js.map +1 -1
  6. package/dist/adapters/hotcrp/index.d.ts +6 -4
  7. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  8. package/dist/adapters/hotcrp/index.js +6 -4
  9. package/dist/adapters/hotcrp/index.js.map +1 -1
  10. package/dist/adapters/hotcrp/portal.io.d.ts +3 -1
  11. package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -1
  12. package/dist/adapters/hotcrp/portal.io.js +36 -11
  13. package/dist/adapters/hotcrp/portal.io.js.map +1 -1
  14. package/dist/adapters/hotcrp/response.d.ts +6 -1
  15. package/dist/adapters/hotcrp/response.d.ts.map +1 -1
  16. package/dist/adapters/hotcrp/response.js +17 -0
  17. package/dist/adapters/hotcrp/response.js.map +1 -1
  18. package/dist/adapters/hotcrp/site.d.ts +9 -0
  19. package/dist/adapters/hotcrp/site.d.ts.map +1 -0
  20. package/dist/adapters/hotcrp/site.js +22 -0
  21. package/dist/adapters/hotcrp/site.js.map +1 -0
  22. package/dist/adapters/hotcrp/token.d.ts +13 -0
  23. package/dist/adapters/hotcrp/token.d.ts.map +1 -0
  24. package/dist/adapters/hotcrp/token.js +48 -0
  25. package/dist/adapters/hotcrp/token.js.map +1 -0
  26. package/dist/adapters/latex/abstract.d.ts +22 -0
  27. package/dist/adapters/latex/abstract.d.ts.map +1 -0
  28. package/dist/adapters/latex/abstract.js +187 -0
  29. package/dist/adapters/latex/abstract.js.map +1 -0
  30. package/dist/adapters/latex/captions.d.ts +9 -0
  31. package/dist/adapters/latex/captions.d.ts.map +1 -0
  32. package/dist/adapters/latex/captions.js +40 -0
  33. package/dist/adapters/latex/captions.js.map +1 -0
  34. package/dist/adapters/latex/headings.d.ts +6 -0
  35. package/dist/adapters/latex/headings.d.ts.map +1 -1
  36. package/dist/adapters/latex/headings.js +5 -4
  37. package/dist/adapters/latex/headings.js.map +1 -1
  38. package/dist/adapters/latex/index.d.ts +1 -0
  39. package/dist/adapters/latex/index.d.ts.map +1 -1
  40. package/dist/adapters/latex/index.js +3 -0
  41. package/dist/adapters/latex/index.js.map +1 -1
  42. package/dist/adapters/latex/parse.d.ts +15 -0
  43. package/dist/adapters/latex/parse.d.ts.map +1 -1
  44. package/dist/adapters/latex/parse.js +64 -1
  45. package/dist/adapters/latex/parse.js.map +1 -1
  46. package/dist/adapters/node/list-entries.io.d.ts.map +1 -1
  47. package/dist/adapters/node/list-entries.io.js +3 -1
  48. package/dist/adapters/node/list-entries.io.js.map +1 -1
  49. package/dist/cli.d.ts.map +1 -1
  50. package/dist/cli.js +20 -8
  51. package/dist/cli.js.map +1 -1
  52. package/dist/cycle-rules.d.ts.map +1 -1
  53. package/dist/cycle-rules.js +3 -4
  54. package/dist/cycle-rules.js.map +1 -1
  55. package/dist/domain/deadline.d.ts +8 -1
  56. package/dist/domain/deadline.d.ts.map +1 -1
  57. package/dist/domain/deadline.js +26 -12
  58. package/dist/domain/deadline.js.map +1 -1
  59. package/dist/domain/decide-first.d.ts +11 -1
  60. package/dist/domain/decide-first.d.ts.map +1 -1
  61. package/dist/domain/decide-first.js +6 -0
  62. package/dist/domain/decide-first.js.map +1 -1
  63. package/dist/domain/index-columns.d.ts.map +1 -1
  64. package/dist/domain/index-columns.js +2 -0
  65. package/dist/domain/index-columns.js.map +1 -1
  66. package/dist/domain/portal-token.d.ts +44 -0
  67. package/dist/domain/portal-token.d.ts.map +1 -0
  68. package/dist/domain/portal-token.js +32 -0
  69. package/dist/domain/portal-token.js.map +1 -0
  70. package/dist/domain/study-registry.d.ts +65 -8
  71. package/dist/domain/study-registry.d.ts.map +1 -1
  72. package/dist/domain/study-registry.js +63 -19
  73. package/dist/domain/study-registry.js.map +1 -1
  74. package/dist/domain/submission.d.ts +30 -0
  75. package/dist/domain/submission.d.ts.map +1 -1
  76. package/dist/domain/submission.js +68 -0
  77. package/dist/domain/submission.js.map +1 -1
  78. package/dist/lib/paper-config.d.ts +1 -1
  79. package/dist/lib/paper-config.d.ts.map +1 -1
  80. package/dist/lib/paper-config.js +6 -4
  81. package/dist/lib/paper-config.js.map +1 -1
  82. package/dist/outputs-before-decision-rule.d.ts +1 -1
  83. package/dist/outputs-before-decision-rule.d.ts.map +1 -1
  84. package/dist/outputs-before-decision-rule.js +21 -17
  85. package/dist/outputs-before-decision-rule.js.map +1 -1
  86. package/dist/paper-settings.d.ts +3 -1
  87. package/dist/paper-settings.d.ts.map +1 -1
  88. package/dist/paper-settings.js +11 -5
  89. package/dist/paper-settings.js.map +1 -1
  90. package/dist/ports/latex.d.ts +23 -0
  91. package/dist/ports/latex.d.ts.map +1 -1
  92. package/dist/ports/list-entries.d.ts +7 -4
  93. package/dist/ports/list-entries.d.ts.map +1 -1
  94. package/dist/ports/study-deps.d.ts +14 -0
  95. package/dist/ports/study-deps.d.ts.map +1 -0
  96. package/dist/ports/study-deps.js +2 -0
  97. package/dist/ports/study-deps.js.map +1 -0
  98. package/dist/ports/study-folder.d.ts +43 -0
  99. package/dist/ports/study-folder.d.ts.map +1 -0
  100. package/dist/ports/study-folder.js +2 -0
  101. package/dist/ports/study-folder.js.map +1 -0
  102. package/dist/ports/submission-portal.d.ts +39 -2
  103. package/dist/ports/submission-portal.d.ts.map +1 -1
  104. package/dist/ports/venue-deadlines.d.ts +1 -1
  105. package/dist/presets.d.ts +1 -2
  106. package/dist/presets.d.ts.map +1 -1
  107. package/dist/presets.js +1 -2
  108. package/dist/presets.js.map +1 -1
  109. package/dist/studies.d.ts.map +1 -1
  110. package/dist/studies.js +10 -7
  111. package/dist/studies.js.map +1 -1
  112. package/dist/study-folder.d.ts +14 -0
  113. package/dist/study-folder.d.ts.map +1 -0
  114. package/dist/study-folder.js +123 -0
  115. package/dist/study-folder.js.map +1 -0
  116. package/dist/study-gate.d.ts +1 -1
  117. package/dist/study-gate.d.ts.map +1 -1
  118. package/dist/study-gate.js +65 -28
  119. package/dist/study-gate.js.map +1 -1
  120. package/dist/study-index-input.d.ts +1 -1
  121. package/dist/study-index-input.d.ts.map +1 -1
  122. package/dist/study-index-input.js +8 -3
  123. package/dist/study-index-input.js.map +1 -1
  124. package/dist/study-modes.d.ts +38 -27
  125. package/dist/study-modes.d.ts.map +1 -1
  126. package/dist/study-modes.js +42 -53
  127. package/dist/study-modes.js.map +1 -1
  128. package/dist/study-registry-rule.d.ts +2 -6
  129. package/dist/study-registry-rule.d.ts.map +1 -1
  130. package/dist/study-registry-rule.js +32 -41
  131. package/dist/study-registry-rule.js.map +1 -1
  132. package/dist/submission.d.ts +49 -11
  133. package/dist/submission.d.ts.map +1 -1
  134. package/dist/submission.js +332 -44
  135. package/dist/submission.js.map +1 -1
  136. package/docs/configuration.md +2 -1
  137. package/docs/design/study-modes.md +46 -28
  138. package/docs/rules/cycle/record.md +9 -6
  139. package/docs/rules/repro/outputs-before-decision.md +3 -0
  140. package/docs/rules/repro/study-registered.md +3 -2
  141. package/docs/rules/tex/heading-case.md +1 -1
  142. package/docs/submission.md +47 -48
  143. package/eslint.config.mjs +39 -24
  144. package/lib/paper-config.ts +7 -4
  145. package/package.json +6 -1
  146. package/presets/msr.jsonc +9 -7
  147. package/presets/msr.md +14 -10
  148. package/scripts/check.ts +1 -1
  149. package/scripts/refresh-deadlines/compare.test.ts +75 -0
  150. package/scripts/refresh-deadlines/compare.ts +101 -0
  151. package/scripts/refresh-deadlines/main.test.ts +140 -0
  152. package/scripts/refresh-deadlines/main.ts +56 -0
  153. package/scripts/refresh-deadlines/refresh.test.ts +287 -0
  154. package/scripts/refresh-deadlines/refresh.ts +183 -0
  155. package/skills/grade-paper-writing/SKILL.md +4 -2
  156. package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -1
  157. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +16 -3
  158. package/skills/grade-paper-writing/prose-lint.mjs +127 -95
  159. package/skills/grade-paper-writing/prose-lint.test.ts +119 -10
  160. package/skills/submission-portal/SKILL.md +60 -44
  161. package/skills/submission-portal/SKILL.md.spec.ts +62 -43
  162. package/skills/submission-portal/submission-portal.test.ts +4 -73
  163. package/src/CLAUDE.md +7 -4
  164. package/src/CLAUDE.md.spec.ts +6 -3
  165. package/src/adapters/hotcrp/cassette/README.md +4 -4
  166. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +9 -9
  167. package/src/adapters/hotcrp/deadlines.io.test.ts +2 -2
  168. package/src/adapters/hotcrp/deadlines.test.ts +3 -3
  169. package/src/adapters/hotcrp/index.ts +15 -3
  170. package/src/adapters/hotcrp/portal.io.ts +61 -17
  171. package/src/adapters/hotcrp/portal.test.ts +105 -1
  172. package/src/adapters/hotcrp/response.ts +21 -0
  173. package/src/adapters/hotcrp/site.ts +34 -0
  174. package/src/adapters/hotcrp/token.test.ts +69 -0
  175. package/src/adapters/hotcrp/token.ts +51 -0
  176. package/src/adapters/latex/abstract.test.ts +280 -0
  177. package/src/adapters/latex/abstract.ts +272 -0
  178. package/src/adapters/latex/captions.test.ts +57 -0
  179. package/src/adapters/latex/captions.ts +56 -0
  180. package/src/adapters/latex/headings.ts +5 -4
  181. package/src/adapters/latex/index.test.ts +31 -1
  182. package/src/adapters/latex/index.ts +3 -0
  183. package/src/adapters/latex/parse.ts +71 -3
  184. package/src/adapters/node/list-entries.io.ts +2 -1
  185. package/src/adapters/node/list-entries.test.ts +18 -0
  186. package/src/cli.test.ts +20 -8
  187. package/src/cli.ts +22 -13
  188. package/src/cycle-rules.test.ts +6 -6
  189. package/src/cycle-rules.ts +7 -4
  190. package/src/domain/deadline.test.ts +31 -2
  191. package/src/domain/deadline.ts +42 -16
  192. package/src/domain/decide-first.test.ts +33 -0
  193. package/src/domain/decide-first.ts +16 -1
  194. package/src/domain/index-columns.test.ts +49 -4
  195. package/src/domain/index-columns.ts +2 -0
  196. package/src/domain/portal-token.test.ts +55 -0
  197. package/src/domain/portal-token.ts +63 -0
  198. package/src/domain/study-measurements.test.ts +7 -3
  199. package/src/domain/study-registry.test.ts +34 -6
  200. package/src/domain/study-registry.ts +123 -31
  201. package/src/domain/submission.test.ts +88 -0
  202. package/src/domain/submission.ts +99 -0
  203. package/src/new-paper.test.ts +1 -0
  204. package/src/outputs-before-decision-rule.test.ts +47 -2
  205. package/src/outputs-before-decision-rule.ts +31 -22
  206. package/src/paper-settings.test.ts +20 -0
  207. package/src/paper-settings.ts +17 -5
  208. package/src/ports/latex.ts +25 -0
  209. package/src/ports/list-entries.ts +7 -4
  210. package/src/ports/study-deps.ts +14 -0
  211. package/src/ports/study-folder.ts +38 -0
  212. package/src/ports/submission-portal.ts +43 -1
  213. package/src/ports/venue-deadlines.ts +1 -1
  214. package/src/presets.test.ts +8 -5
  215. package/src/presets.ts +1 -2
  216. package/src/studies-index-modes.test.ts +2 -2
  217. package/src/studies.ts +19 -10
  218. package/src/study-folder.test.ts +237 -0
  219. package/src/study-folder.ts +176 -0
  220. package/src/study-gate.test.ts +158 -7
  221. package/src/study-gate.ts +127 -54
  222. package/src/study-index-input.ts +12 -4
  223. package/src/study-modes.ts +67 -80
  224. package/src/study-registry-rule.test.ts +87 -0
  225. package/src/study-registry-rule.ts +58 -66
  226. package/src/submission-new.test.ts +569 -0
  227. package/src/submission-show.test.ts +210 -0
  228. package/src/submission-update.test.ts +160 -0
  229. package/src/submission.test.ts +140 -518
  230. package/src/submission.ts +494 -61
  231. package/scripts/refresh-deadlines.test.ts +0 -431
  232. package/scripts/refresh-deadlines.ts +0 -387
@@ -1,461 +1,161 @@
1
1
  /**
2
- * `paperlint submission` over in-memory files and a recording portal: where the portal and the
3
- * submission id come from, the refusals that name the file and key to set, the token rule, and what
4
- * `show` and `update` print and exit with. The HotCRP wire format is tested in the adapter.
2
+ * What every `paperlint submission` subcommand shares: the command line, where the portal and the
3
+ * submission id come from, the token rule and its messages, a failure in words. Each subcommand's
4
+ * own behaviour is in submission-new, submission-show and submission-update. The HotCRP wire format
5
+ * is tested in the adapter.
5
6
  */
6
- import assert from "node:assert/strict";
7
- import { test } from "vitest";
7
+ import { map } from "remeda";
8
+ import { expect, test } from "vitest";
9
+ import { hotcrpSite } from "./adapters/hotcrp/index.ts";
8
10
  import { memoryFiles } from "./adapters/memory/index.ts";
9
- import { nodeFiles } from "./adapters/node/index.ts";
10
- import type { Files } from "./ports/files.ts";
11
- import { ok, err, type Result } from "./domain/result.ts";
12
- import { sha256Hex } from "./domain/sha256.ts";
13
- import type {
14
- PortalFailure,
15
- SubmissionChange,
16
- SubmissionView,
17
- SupportedPortal,
18
- UpdateOutcome,
19
- } from "./domain/submission.ts";
11
+ import { err } from "./domain/result.ts";
20
12
  import { presetsDir } from "./package-dirs.ts";
21
- import type { SubmissionPortal } from "./ports/submission-portal.ts";
13
+ import { type Files } from "./ports/files.ts";
22
14
  import {
23
15
  failureText,
24
- runSubmission,
25
- deadlineLine,
26
16
  submissionTarget,
27
- tokenFor,
17
+ tokenProblemLines,
18
+ tokenRefusedLines,
28
19
  usageProblem,
29
- type SubmissionArgs,
30
20
  } from "./submission.ts";
31
- import { venuePreset } from "../test/support.ts";
32
-
33
- const PAPER = "/w/papers/p";
34
- const SITE = "http://127.0.0.1:9";
35
- const PDF = new TextEncoder().encode("%PDF fake");
36
- const HEX = sha256Hex(PDF);
37
-
38
- /** The test's files, over the disk for what they do not hold: the shipped presets and their schema. */
39
- const withPresets = (fs: Files): Files => ({
40
- ...fs,
41
- isFile: (p) => fs.isFile(p) || nodeFiles.isFile(p),
42
- readBytes: (p) => fs.readBytes(p) ?? nodeFiles.readBytes(p),
43
- });
44
-
45
- /** A venue over aidc's format with a portal of its own — so none of aidc's deadline readings. */
46
- const venue = (portal: unknown): string =>
47
- JSON.stringify(
48
- venuePreset("venue", { extends: "paperlint:aidc", portal, deadlines: [] }),
49
- );
50
- const files = (o: {
51
- readonly portal?: unknown;
52
- readonly paper?: Readonly<Record<string, unknown>>;
53
- readonly extra?: Readonly<Record<string, string | Uint8Array>>;
54
- }) =>
55
- memoryFiles({
56
- "/w/package.json": "{}",
57
- "/w/venue.jsonc": venue(o.portal ?? { kind: "hotcrp", url: `${SITE}/` }),
58
- [`${PAPER}/paperlint.json`]: JSON.stringify(
59
- o.paper ?? { extends: "../../venue.jsonc", submission: { id: 7 } },
60
- ),
61
- [`${PAPER}/paper.pdf`]: PDF,
62
- ...o.extra,
63
- });
64
-
65
- const VIEW: SubmissionView = {
66
- id: 7,
67
- status: "submitted",
68
- title: "A Fake Title",
69
- paperType: "Regular",
70
- topics: ["A", "B"],
71
- abstract: "one two three\nfour",
72
- pdf: {
73
- sha256: HEX,
74
- hash: `sha2-${HEX}`,
75
- size: PDF.length,
76
- mimetype: "application/pdf",
77
- uploadedAt: 0,
78
- },
79
- submittedAt: 60,
80
- modifiedAt: 120,
81
- messages: [],
82
- };
83
-
84
- interface Recorded {
85
- readonly portal: SupportedPortal;
86
- readonly token: string;
87
- readonly calls: unknown[];
88
- }
89
-
90
- const fakePortal = (answers: {
91
- readonly show?: Result<SubmissionView, PortalFailure>;
92
- readonly update?: Result<UpdateOutcome, PortalFailure>;
93
- }) => {
94
- const seen: Recorded[] = [];
95
- const portalFor = (
96
- portal: SupportedPortal,
97
- token: string,
98
- ): SubmissionPortal => {
99
- const r: Recorded = { portal, token, calls: [] };
100
- seen.push(r);
101
- return {
102
- show: (id) => {
103
- r.calls.push(["show", id]);
104
- return Promise.resolve(answers.show ?? ok(VIEW));
105
- },
106
- update: (id, change: SubmissionChange, o) => {
107
- r.calls.push(["update", id, change, o]);
108
- return Promise.resolve(
109
- answers.update ??
110
- ok({
111
- httpStatus: 200,
112
- valid: true,
113
- changes: ["submission"],
114
- messages: [],
115
- dryRun: !o.save,
116
- }),
117
- );
118
- },
119
- };
120
- };
121
- return { seen, portalFor };
122
- };
123
-
124
- /** The line `update` prints before it sends `pdf`, naming `call`. */
125
- const CHECK = (pdf: string, call: string): string =>
126
- `before you rely on it, open ${pdf} yourself and check it against ${call}: the page count and what counts toward the limit, the template and its class options, anonymity, and that every figure, table and reference renders — paperlint passing is not the venue accepting the format`;
127
-
128
- const ARGS: SubmissionArgs = {
129
- sub: "show",
130
- paperDir: "papers/p",
131
- extra: [],
132
- pdf: null,
133
- abstract: null,
134
- submit: false,
135
- save: false,
136
- };
137
-
138
- async function run(
139
- a: Partial<SubmissionArgs>,
140
- o: {
141
- readonly files?: Files;
142
- readonly env?: Readonly<Record<string, string>>;
143
- readonly portal?: ReturnType<typeof fakePortal>;
144
- } = {},
145
- ) {
146
- const out: string[] = [];
147
- const errs: string[] = [];
148
- const portal = o.portal ?? fakePortal({});
149
- const fs = withPresets(o.files ?? files({}));
150
- const code = await runSubmission(
151
- { ...ARGS, ...a },
152
- {
153
- files: fs,
154
- presets: { files: fs, venuesDir: presetsDir() },
155
- cwd: "/w",
156
- env: (n) => (o.env ?? { HOTCRP_TOKEN: "hct_secret" })[n],
157
- portalFor: portal.portalFor,
158
- log: (l) => out.push(l),
159
- err: (l) => errs.push(l),
160
- },
161
- );
162
- return { code, out, errs, seen: portal.seen };
163
- }
164
-
165
- test("show: the portal from the preset, the id from the paper, the token from HOTCRP_TOKEN", async () => {
166
- const r = await run({});
167
- assert.equal(r.code, 0);
168
- assert.deepEqual(r.errs, []);
169
- assert.deepEqual(r.seen, [
170
- {
171
- portal: { kind: "hotcrp", url: SITE },
172
- token: "hct_secret",
173
- calls: [["show", 7]],
174
- },
175
- ]);
176
- assert.deepEqual(r.out, [
177
- `submission 7 on ${SITE} (hotcrp)`,
178
- "status submitted, submitted 1970-01-01T00:01:00.000Z",
179
- "title A Fake Title",
180
- "paper type Regular",
181
- "topics A; B",
182
- "abstract 4 words",
183
- `portal PDF sha2-${HEX}, ${String(PDF.length)} bytes, uploaded 1970-01-01T00:00:00.000Z`,
184
- `local PDF papers/p/paper.pdf sha2-${HEX}`,
185
- "match yes — the portal holds this build",
186
- ]);
187
- // Guards: the token is an input to the adapter and nothing printed carries it.
188
- assert.doesNotMatch([...r.out, ...r.errs].join("\n"), /hct_secret/);
189
- });
190
-
191
- test("show: a different PDF on the portal is reported as NO, and still exits 0", async () => {
192
- const other = {
193
- ...VIEW,
194
- pdf: VIEW.pdf && { ...VIEW.pdf, sha256: sha256Hex(new Uint8Array([1])) },
195
- };
196
- const r = await run({}, { portal: fakePortal({ show: ok(other) }) });
197
- assert.equal(r.code, 0);
198
- assert.equal(
199
- r.out.at(-1),
200
- "match NO — the portal holds a different PDF than the local one",
201
- );
202
- });
203
-
204
- test("show: no PDF on the portal, a hash that is not sha256, no local PDF, portal messages", async () => {
205
- const bare: SubmissionView = {
206
- ...VIEW,
207
- title: null,
208
- paperType: null,
209
- topics: [],
210
- abstract: null,
211
- pdf: null,
212
- submittedAt: null,
213
- messages: [
214
- { message: "Not yet submitted", field: "status", status: 1 },
215
- { message: "Draft", field: null, status: null },
216
- ],
217
- };
218
- const r1 = await run({}, { portal: fakePortal({ show: ok(bare) }) });
219
- assert.deepEqual(r1.out.slice(1), [
220
- "status submitted",
221
- "title —",
222
- "paper type —",
223
- "topics —",
224
- "abstract —",
225
- "portal PDF none",
226
- `local PDF papers/p/paper.pdf sha2-${HEX}`,
227
- "match NO — the portal holds no PDF",
228
- " portal says: status: Not yet submitted",
229
- " portal says: Draft",
230
- ]);
231
- const sha1 = {
232
- ...VIEW,
233
- pdf: {
234
- hash: "sha1-abc",
235
- sha256: null,
236
- size: null,
237
- mimetype: null,
238
- uploadedAt: null,
239
- },
240
- };
241
- const r2 = await run({}, { portal: fakePortal({ show: ok(sha1) }) });
242
- assert.equal(r2.out[6], "portal PDF sha1-abc, ? bytes, uploaded —");
243
- assert.equal(
244
- r2.out[8],
245
- "match unknown — the portal names a sha1 hash, not a sha256",
246
- );
247
- const r4 = await run({ pdf: "/elsewhere/x.pdf" });
248
- assert.equal(r4.out[7], "local PDF /elsewhere/x.pdf (missing)");
249
- const r3 = await run({ pdf: "missing.pdf" });
250
- assert.equal(r3.out[7], "local PDF missing.pdf (missing)");
251
- assert.equal(r3.out[8], "match unknown — no local PDF to compare");
252
- });
253
-
254
- test("🔴 the portal's refusal is printed with its own message, exit 1", async () => {
255
- const refused = err<PortalFailure>({
256
- kind: "refused",
257
- httpStatus: 401,
258
- messages: [{ message: "Missing credentials", field: null, status: 2 }],
259
- });
260
- const r = await run({}, { portal: fakePortal({ show: refused }) });
261
- assert.equal(r.code, 1);
262
- assert.deepEqual(r.errs, [
263
- "the portal refused (HTTP 401): Missing credentials",
264
- ]);
265
- });
21
+ import {
22
+ PAPER,
23
+ SITE,
24
+ withPresets,
25
+ files,
26
+ fakePortal,
27
+ ARGS,
28
+ run,
29
+ STEPS,
30
+ newFiles,
31
+ } from "../test/submission-fixtures.ts";
266
32
 
267
33
  test("failureText: each failure in words", () => {
268
- assert.equal(
269
- failureText({ kind: "refused", httpStatus: 500, messages: [] }),
34
+ expect(failureText({ kind: "refused", httpStatus: 500, messages: [] })).toBe(
270
35
  "the portal refused (HTTP 500)",
271
36
  );
272
- assert.equal(
273
- failureText({ kind: "unreachable", detail: "fetch failed" }),
37
+ expect(failureText({ kind: "unreachable", detail: "fetch failed" })).toBe(
274
38
  "the portal cannot be reached: fetch failed",
275
39
  );
276
- assert.equal(
40
+ expect(
277
41
  failureText({
278
42
  kind: "malformed",
279
43
  httpStatus: 200,
280
44
  detail: "the answer is not JSON (text/html)",
281
45
  }),
46
+ ).toBe(
282
47
  "the portal answered HTTP 200, but not as its API does: the answer is not JSON (text/html)",
283
48
  );
284
49
  });
285
50
 
286
- test("update: a dry run by default — the paper's PDF sent, nothing saved, exit 0", async () => {
287
- const r = await run({ sub: "update" });
288
- assert.equal(r.code, 0);
289
- assert.deepEqual(r.seen[0]?.calls, [
290
- [
291
- "update",
292
- 7,
293
- { pdf: { name: "paper.pdf", bytes: PDF }, abstract: null, submit: false },
294
- { save: false },
295
- ],
296
- ]);
297
- assert.deepEqual(r.out, [
298
- CHECK("papers/p/paper.pdf", "the call for papers, https://example.org/cfp"),
299
- "dry run — the portal checked the change and kept nothing; --save sends it for real",
300
- "HTTP 200",
301
- "valid yes",
302
- "changes submission",
303
- `local PDF papers/p/paper.pdf sha2-${HEX}`,
51
+ test("no token: the site's variable is named, with this site's token page, the scopes and the export — and nothing is called", async () => {
52
+ const r = await run({}, { env: {} });
53
+ expect(r.code).toBe(2);
54
+ expect(r.seen).toStrictEqual([]);
55
+ expect(r.errs).toStrictEqual([
56
+ `no hotcrp token for ${SITE}: HOTCRP_TOKEN_127_0_0_1_9 is not set`,
57
+ ...STEPS,
304
58
  ]);
305
59
  });
306
60
 
307
- test("update --save --submit --abstract --pdf: all of it sent, and said to be saved", async () => {
308
- const fs = files({
309
- extra: { "/w/abs.txt": " The abstract.\n", "/w/other.pdf": PDF },
310
- });
61
+ test("only the site-less HOTCRP_TOKEN set: the rename instruction, and nothing is sent with it", async () => {
311
62
  const r = await run(
312
- {
313
- sub: "update",
314
- save: true,
315
- submit: true,
316
- abstract: "abs.txt",
317
- pdf: "other.pdf",
318
- },
319
- { files: fs },
63
+ { sub: "update", save: true },
64
+ { env: { HOTCRP_TOKEN: "hct_old" } },
320
65
  );
321
- assert.equal(r.code, 0);
322
- assert.deepEqual(r.seen[0]?.calls, [
323
- [
324
- "update",
325
- 7,
326
- {
327
- pdf: { name: "other.pdf", bytes: PDF },
328
- abstract: "The abstract.",
329
- submit: true,
330
- },
331
- { save: true },
332
- ],
333
- ]);
334
- // Guards: the PDF that is sent is the one named, and --save is told to check it too.
335
- assert.deepEqual(r.out.slice(0, 2), [
336
- CHECK("other.pdf", "the call for papers, https://example.org/cfp"),
337
- "saved",
66
+ expect(r.code).toBe(2);
67
+ expect(r.seen).toStrictEqual([]);
68
+ expect(r.errs).toStrictEqual([
69
+ "HOTCRP_TOKEN is no longer read: tokens are per hotcrp site. If it was made on 127.0.0.1:9, rename it to HOTCRP_TOKEN_127_0_0_1_9; if it was made on another site, rename it to that site's variable (HOTCRP_TOKEN_<LABEL>) and create a token here:",
70
+ ...STEPS,
338
71
  ]);
72
+ expect(r.errs.join("\n")).not.toMatch(/hct_old/);
339
73
  });
340
74
 
341
- test("🔴 update: the check line names the venue's call from the preset's `url`; a preset that names no venue gets the generic line", async () => {
342
- const family = memoryFiles({
343
- "/w/package.json": "{}",
344
- "/w/house.jsonc": JSON.stringify({
345
- type: "family",
346
- extends: "paperlint:ieee-conference",
347
- portal: { kind: "hotcrp", url: `${SITE}/` },
348
- }),
349
- [`${PAPER}/paperlint.json`]: JSON.stringify({
350
- extends: "../../house.jsonc",
351
- submission: { id: 7 },
352
- }),
353
- [`${PAPER}/paper.pdf`]: PDF,
354
- });
355
- const r = await run({ sub: "update" }, { files: family });
356
- assert.equal(r.code, 0);
357
- assert.equal(
358
- r.out[0],
359
- CHECK("papers/p/paper.pdf", "the venue's call for papers"),
360
- );
361
- });
362
-
363
- test("show sends nothing, so it asks for no check", async () => {
364
- const r = await run({});
365
- assert.equal(
366
- r.out.some((l) => l.includes("call for papers")),
367
- false,
75
+ test("the site's variable set beside a site-less one: the site's token is the one used", async () => {
76
+ const r = await run(
77
+ {},
78
+ { env: { HOTCRP_TOKEN_127_0_0_1_9: "hct_site", HOTCRP_TOKEN: "hct_old" } },
368
79
  );
80
+ expect(r.code).toBe(0);
81
+ expect(r.seen[0]?.token).toBe("hct_site");
369
82
  });
370
83
 
371
- test("update: valid:false, or a non-2xx status, exits 1 with the portal's messages", async () => {
372
- const invalid = ok<UpdateOutcome>({
373
- httpStatus: 200,
374
- valid: false,
375
- changes: [],
376
- messages: [{ message: "Bad PDF", field: "submission", status: 2 }],
377
- dryRun: true,
378
- });
379
- const r1 = await run(
380
- { sub: "update" },
381
- { portal: fakePortal({ update: invalid }) },
382
- );
383
- assert.equal(r1.code, 1);
384
- assert.deepEqual(r1.out.slice(3), [
385
- "valid NO",
386
- "changes —",
387
- `local PDF papers/p/paper.pdf sha2-${HEX}`,
388
- " portal says: submission: Bad PDF",
389
- ]);
390
- const status = ok<UpdateOutcome>({
391
- httpStatus: 400,
392
- valid: true,
393
- changes: [],
394
- messages: [],
395
- dryRun: false,
396
- });
397
- assert.equal(
398
- (await run({ sub: "update" }, { portal: fakePortal({ update: status }) }))
399
- .code,
400
- 1,
401
- );
402
- const unreachable = err<PortalFailure>({
403
- kind: "unreachable",
404
- detail: "down",
84
+ test("a 401 from the portal: the portal's words, which variable was used, and how to make this site's token", async () => {
85
+ const refused = err({
86
+ kind: "refused" as const,
87
+ httpStatus: 401,
88
+ messages: [{ message: "Unauthorized", field: null, status: 2 }],
405
89
  });
406
- const r3 = await run(
407
- { sub: "update" },
408
- { portal: fakePortal({ update: unreachable }) },
90
+ const subs = ["show", "update", "new"] as const;
91
+ const runs = await Promise.all(
92
+ map(subs, (sub) =>
93
+ run(
94
+ { sub },
95
+ {
96
+ portal: fakePortal({
97
+ show: refused,
98
+ update: refused,
99
+ create: refused,
100
+ }),
101
+ files: sub === "new" ? newFiles({}) : files({}),
102
+ },
103
+ ),
104
+ ),
409
105
  );
410
- assert.equal(r3.code, 1);
411
- assert.deepEqual(r3.errs, ["the portal cannot be reached: down"]);
412
- });
413
-
414
- test("update --save that the portal withheld says so", async () => {
415
- const withheld = ok<UpdateOutcome>({
416
- httpStatus: 200,
417
- valid: true,
418
- changes: [],
419
- messages: [],
420
- dryRun: true,
421
- });
422
- const r = await run(
423
- { sub: "update", save: true },
424
- { portal: fakePortal({ update: withheld }) },
106
+ const said = [
107
+ "the portal refused (HTTP 401): Unauthorized",
108
+ "HOTCRP_TOKEN_127_0_0_1_9 was used; it may belong to another hotcrp site (tokens are per site), or be revoked or expired",
109
+ ...STEPS,
110
+ ];
111
+ expect(map(runs, (r) => [r.code, r.errs])).toStrictEqual(
112
+ map(subs, () => [1, said]),
425
113
  );
426
- assert.equal(r.out[1], "SAVE WITHHELD by the portal — nothing changed");
427
114
  });
428
115
 
429
- test("update: no PDF to send, or no abstract file, is refused before the portal is asked", async () => {
430
- const r1 = await run({ sub: "update", pdf: "nope.pdf" });
431
- assert.equal(r1.code, 2);
432
- assert.deepEqual(r1.errs, [
433
- "no PDF at nope.pdf — build the paper, or name one with --pdf",
116
+ test("a 403 names a missing scope; any other failure says nothing about the token", () => {
117
+ const portal = { kind: "hotcrp", url: SITE } as const;
118
+ const f = (httpStatus: number) =>
119
+ ({ kind: "refused", httpStatus, messages: [] }) as const;
120
+ expect(tokenRefusedLines(portal, hotcrpSite(SITE), f(403))).toStrictEqual([
121
+ "HOTCRP_TOKEN_127_0_0_1_9 was used; the site turned the request away — the token may lack a scope this subcommand needs",
122
+ ...STEPS,
123
+ ]);
124
+ expect(tokenRefusedLines(portal, hotcrpSite(SITE), f(500))).toStrictEqual([]);
125
+ expect(
126
+ tokenRefusedLines(portal, hotcrpSite(SITE), {
127
+ kind: "unreachable",
128
+ detail: "x",
129
+ }),
130
+ ).toStrictEqual([]);
131
+ expect(
132
+ tokenProblemLines(portal, hotcrpSite(SITE), {
133
+ kind: "missing",
134
+ variable: "HOTCRP_TOKEN_127_0_0_1_9",
135
+ }),
136
+ ).toStrictEqual([
137
+ `no hotcrp token for ${SITE}: HOTCRP_TOKEN_127_0_0_1_9 is not set`,
138
+ ...STEPS,
434
139
  ]);
435
- assert.deepEqual(r1.seen[0]?.calls, []);
436
- const r2 = await run({ sub: "update", abstract: "nope.txt" });
437
- assert.equal(r2.code, 2);
438
- assert.deepEqual(r2.errs, ["--abstract: no file at nope.txt"]);
439
140
  });
440
141
 
441
- test("🔴 no token: the variable is named, with where to make one — and nothing is called", async () => {
442
- const r = await run({}, { env: {} });
443
- assert.equal(r.code, 2);
444
- assert.deepEqual(r.seen, []);
445
- assert.match(
446
- r.errs[0] ?? "",
447
- /^HOTCRP_TOKEN is not set — .*Account settings → Developer/,
142
+ test("show and update without a submission id: the key to set, or `new` — nothing sent", async () => {
143
+ const noId = files({ paper: { extends: "../../venue.jsonc" } });
144
+ const noIdRuns = await Promise.all(
145
+ map(["show", "update"] as const, (sub) => run({ sub }, { files: noId })),
448
146
  );
449
- assert.deepEqual(
450
- tokenFor({ kind: "hotcrp", url: SITE }, () => " "),
451
- {
452
- ok: false,
453
- error: r.errs[0],
454
- },
147
+ expect(map(noIdRuns, (r) => [r.code, r.seen, r.errs])).toStrictEqual(
148
+ map(noIdRuns, () => [
149
+ 2,
150
+ [],
151
+ [
152
+ `${PAPER}/paperlint.json: no "submission" — add "submission": { "id": <the submission number on ${SITE}> }, or create the submission with \`paperlint submission new\``,
153
+ ],
154
+ ]),
455
155
  );
456
156
  });
457
157
 
458
- test("🔴 the file and key to set: no portal in the preset, no submission in the paper, no preset", () => {
158
+ test("the file and key to set: no portal in the preset, no submission in the paper, no preset", () => {
459
159
  const deps = (fs: Files) => ({
460
160
  files: withPresets(fs),
461
161
  venuesDir: presetsDir(),
@@ -468,18 +168,12 @@ test("🔴 the file and key to set: no portal in the preset, no submission in th
468
168
  }),
469
169
  });
470
170
  const t1 = submissionTarget(PAPER, deps(noPortal));
471
- assert.match(
472
- !t1.ok ? t1.error : "",
171
+ expect(!t1.ok ? t1.error : "").toMatch(
473
172
  /^the venue preset declares no "portal" \(read: .*ieee-conference\.jsonc\) — add "portal": \{ "kind": "hotcrp"/,
474
173
  );
475
- const noId = files({ paper: { extends: "../../venue.jsonc" } });
476
- assert.deepEqual(submissionTarget(PAPER, deps(noId)), {
477
- ok: false,
478
- error: `${PAPER}/paperlint.json: no "submission" — add "submission": { "id": <the submission number on ${SITE}/> }`,
479
- });
480
174
  const noExtends = files({ paper: { submission: { id: 7 } } });
481
175
  const t3 = submissionTarget(PAPER, deps(noExtends));
482
- assert.match(!t3.ok ? t3.error : "", /paperlint\.json: no "extends"/);
176
+ expect(!t3.ok ? t3.error : "").toMatch(/paperlint\.json: no "extends"/);
483
177
  // With cycles, `extends` beside them is refused: the message names the cycle, not the key.
484
178
  const named = files({
485
179
  paper: {
@@ -498,27 +192,26 @@ test("🔴 the file and key to set: no portal in the preset, no submission in th
498
192
  },
499
193
  });
500
194
  const t5 = submissionTarget(PAPER, deps(named));
501
- assert.equal(
502
- !t5.ok ? t5.error : "",
195
+ expect(!t5.ok ? t5.error : "").toBe(
503
196
  `${PAPER}/paperlint.json: the current cycle names no venue preset, and the preset is what declares the portal — set the venue in the current cycle ("venue": { "kind": "preset", "extends": "paperlint:…" }), or upload by hand`,
504
197
  );
505
198
  const broken = files({
506
199
  paper: { extends: "../../venue.jsonc", submission: { id: "7" } },
507
200
  });
508
201
  const t4 = submissionTarget(PAPER, deps(broken));
509
- assert.match(!t4.ok ? t4.error : "", /"submission" must be \{ "id"/);
202
+ expect(!t4.ok ? t4.error : "").toMatch(/"submission" must be \{ "id"/);
510
203
  });
511
204
 
512
- test("🔴 an unknown portal kind is refused: upload by hand", async () => {
205
+ test("an unknown portal kind is refused: upload by hand", async () => {
513
206
  const r = await run(
514
207
  {},
515
208
  { files: files({ portal: { kind: "easychair", url: "https://x.test" } }) },
516
209
  );
517
- assert.equal(r.code, 2);
518
- assert.deepEqual(r.errs, [
210
+ expect(r.code).toBe(2);
211
+ expect(r.errs).toStrictEqual([
519
212
  `portal kind "easychair" is not supported; upload by hand`,
520
213
  ]);
521
- assert.deepEqual(r.seen, []);
214
+ expect(r.seen).toStrictEqual([]);
522
215
  });
523
216
 
524
217
  test("the shipped aidc preset declares its HotCRP portal", () => {
@@ -533,108 +226,37 @@ test("the shipped aidc preset declares its HotCRP portal", () => {
533
226
  files: withPresets(fs),
534
227
  venuesDir: presetsDir(),
535
228
  });
536
- assert.equal(t.ok && t.value.portal.kind, "hotcrp");
537
- assert.match(t.ok ? t.value.portal.url : "", /^https:\/\//);
229
+ expect(t.ok && t.value.portal.kind).toBe("hotcrp");
230
+ expect(t.ok ? t.value.portal.url : "").toMatch(/^https:\/\//);
538
231
  });
539
232
 
540
233
  test("usage: refused before anything is read, exit 2", async () => {
541
234
  const r = await run({ sub: "upload" });
542
- assert.equal(r.code, 2);
543
- assert.deepEqual(r.seen, []);
544
- assert.match(r.errs[0] ?? "", /got `upload`/);
235
+ expect(r.code).toBe(2);
236
+ expect(r.seen).toStrictEqual([]);
237
+ expect(r.errs[0] ?? "").toMatch(/got `upload`/);
545
238
  });
546
239
 
547
240
  test("usage: a subcommand is required, one paper folder, update-only flags refused on show", () => {
548
- assert.match(
549
- usageProblem({ ...ARGS, sub: undefined }) ?? "",
550
- /needs a subcommand: show or update — got ``/,
241
+ expect(usageProblem({ ...ARGS, sub: undefined }) ?? "").toMatch(
242
+ /needs a subcommand: new or show or update — got ``/,
551
243
  );
552
- assert.match(usageProblem({ ...ARGS, sub: "upload" }) ?? "", /got `upload`/);
553
- assert.equal(
554
- usageProblem({ ...ARGS, extra: ["x"] }),
244
+ expect(usageProblem({ ...ARGS, sub: "upload" }) ?? "").toMatch(
245
+ /got `upload`/,
246
+ );
247
+ expect(usageProblem({ ...ARGS, extra: ["x"] })).toBe(
555
248
  "`submission show` takes one paper folder — got also `x`",
556
249
  );
557
- assert.equal(
250
+ expect(
558
251
  usageProblem({ ...ARGS, save: true, submit: true, abstract: "a" }),
559
- "--abstract, --submit, --save: only `submission update` changes anything; `show` reads",
252
+ ).toBe(
253
+ "--abstract, --submit, --save: only `submission new` and `submission update` change anything; `show` reads",
254
+ );
255
+ expect(usageProblem({ ...ARGS, sub: "update", save: true })).toBe(null);
256
+ expect(usageProblem({ ...ARGS, sub: "new", save: true, abstract: "a" })).toBe(
257
+ null,
258
+ );
259
+ expect(usageProblem({ ...ARGS, sub: "new", submit: true })).toBe(
260
+ "--submit: `submission new` creates the submission; mark it submitted with `submission update --submit` once `show` says the portal holds this build",
560
261
  );
561
- assert.equal(usageProblem({ ...ARGS, sub: "update", save: true }), null);
562
- });
563
-
564
- test("show prints the current attempt's deadlines in force: the preset's portal readings, the paper's call readings and override over them", () => {
565
- const fs = memoryFiles({
566
- "/w/package.json": "{}",
567
- [`${PAPER}/paperlint.json`]: JSON.stringify({
568
- cycles: [
569
- {
570
- id: "msr-2027",
571
- venue: { kind: "preset", extends: "paperlint:msr" },
572
- kind: "technical",
573
- opened: "2026-10-05",
574
- submission: { id: 7 },
575
- deadlines: [
576
- {
577
- what: "registration",
578
- observed: [
579
- {
580
- at: "2026-10-20 AoE",
581
- source: "call",
582
- url: "https://2027.msrconf.org/cfp",
583
- read: "2026-10-05",
584
- },
585
- ],
586
- },
587
- {
588
- what: "submission",
589
- observed: [
590
- {
591
- at: "2026-10-23 AoE",
592
- source: "call",
593
- url: "https://2027.msrconf.org/cfp",
594
- read: "2026-10-05",
595
- },
596
- ],
597
- },
598
- {
599
- what: "resubmission",
600
- override: {
601
- at: "2026-10-27T04:00:00Z",
602
- reason: "the chairs extended updates by mail",
603
- evidence: "mail/extension.eml",
604
- },
605
- },
606
- ],
607
- },
608
- ],
609
- }),
610
- });
611
- const t = submissionTarget(PAPER, {
612
- files: withPresets(fs),
613
- venuesDir: presetsDir(),
614
- });
615
- assert.ok(t.ok, JSON.stringify(t));
616
- assert.deepEqual(t.value.deadlines.map(deadlineLine), [
617
- "deadline registration 2026-10-21T11:59:59Z — call, read 2026-10-05",
618
- "deadline submission 2026-10-20T04:00:00Z — portal, read 2026-10-05; also call 2026-10-24T11:59:59Z, read 2026-10-05",
619
- "deadline resubmission 2026-10-27T04:00:00Z — override: the chairs extended updates by mail (mail/extension.eml); also portal 2026-10-23T04:00:00Z, read 2026-10-05",
620
- ]);
621
- });
622
-
623
- test("show, in the flat form: the deadlines in force are the preset's readings alone", () => {
624
- const flat = memoryFiles({
625
- "/w/package.json": "{}",
626
- [`${PAPER}/paperlint.json`]: JSON.stringify({
627
- extends: "paperlint:msr",
628
- kind: "technical",
629
- submission: { id: 7 },
630
- }),
631
- });
632
- const f = submissionTarget(PAPER, {
633
- files: withPresets(flat),
634
- venuesDir: presetsDir(),
635
- });
636
- assert.deepEqual(f.ok ? f.value.deadlines.map((d) => [d.what, d.at]) : f, [
637
- ["submission", "2026-10-20T04:00:00Z"],
638
- ["resubmission", "2026-10-23T04:00:00Z"],
639
- ]);
640
262
  });