paperlint 6.2.3 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/dist/adapters/hotcrp/index.d.ts +7 -0
- package/dist/adapters/hotcrp/index.d.ts.map +1 -0
- package/dist/adapters/hotcrp/index.js +7 -0
- package/dist/adapters/hotcrp/index.js.map +1 -0
- package/dist/adapters/hotcrp/portal.io.d.ts +20 -0
- package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -0
- package/dist/adapters/hotcrp/portal.io.js +90 -0
- package/dist/adapters/hotcrp/portal.io.js.map +1 -0
- package/dist/adapters/hotcrp/response.d.ts +13 -0
- package/dist/adapters/hotcrp/response.d.ts.map +1 -0
- package/dist/adapters/hotcrp/response.js +138 -0
- package/dist/adapters/hotcrp/response.js.map +1 -0
- package/dist/cli.d.ts +3 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +109 -34
- package/dist/cli.js.map +1 -1
- package/dist/domain/result.d.ts +4 -0
- package/dist/domain/result.d.ts.map +1 -1
- package/dist/domain/result.js +2 -0
- package/dist/domain/result.js.map +1 -1
- package/dist/domain/submission.d.ts +106 -0
- package/dist/domain/submission.d.ts.map +1 -0
- package/dist/domain/submission.js +39 -0
- package/dist/domain/submission.js.map +1 -0
- package/dist/domain/venue-name.d.ts +38 -0
- package/dist/domain/venue-name.d.ts.map +1 -0
- package/dist/domain/venue-name.js +71 -0
- package/dist/domain/venue-name.js.map +1 -0
- package/dist/folder-venue-rule.d.ts +51 -0
- package/dist/folder-venue-rule.d.ts.map +1 -0
- package/dist/folder-venue-rule.js +96 -0
- package/dist/folder-venue-rule.js.map +1 -0
- package/dist/init.d.ts +6 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +3 -3
- package/dist/init.js.map +1 -1
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +5 -1
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/new-paper.d.ts +19 -5
- package/dist/new-paper.d.ts.map +1 -1
- package/dist/new-paper.js +23 -4
- package/dist/new-paper.js.map +1 -1
- package/dist/paper-settings.d.ts +3 -0
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +20 -4
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/submission-portal.d.ts +19 -0
- package/dist/ports/submission-portal.d.ts.map +1 -0
- package/dist/ports/submission-portal.js +2 -0
- package/dist/ports/submission-portal.js.map +1 -0
- package/dist/presets.d.ts +24 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +35 -3
- package/dist/presets.js.map +1 -1
- package/dist/rules-config.js +1 -1
- package/dist/submission.d.ts +61 -0
- package/dist/submission.d.ts.map +1 -0
- package/dist/submission.js +211 -0
- package/dist/submission.js.map +1 -0
- package/dist/tex-requirements.d.ts +3 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +3 -0
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +6 -7
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +10 -14
- package/dist/tex-venue-rules.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/configuration.md +17 -10
- package/docs/rules/paper/folder-venue-leftover.md +104 -0
- package/docs/rules/talk/captions-cover.md +1 -1
- package/docs/rules/talk/duration-floor.md +1 -1
- package/docs/rules.md +44 -42
- package/docs/skills.md +5 -4
- package/docs/submission.md +64 -0
- package/docs/talk.md +1 -1
- package/lib/paper-config.ts +6 -1
- package/lib/skill-checks.d.mts +7 -0
- package/lib/skill-trigger-cases.mjs +11 -0
- package/package.json +2 -2
- package/presets/aidc.jsonc +6 -0
- package/presets/venue-profile.schema.json +17 -1
- package/skills/paper-pipeline/paper-pipeline.harness.mjs +4 -0
- package/skills/submission-portal/SKILL.md +69 -0
- package/skills/submission-portal/SKILL.md.spec.ts +71 -0
- package/skills/submission-portal/submission-portal.eval.mjs +15 -0
- package/skills/submission-portal/submission-portal.test.ts +81 -0
- package/skills/submit-paper/SKILL.md +2 -1
- package/skills/submit-paper/SKILL.md.spec.ts +1 -0
- package/src/adapters/hotcrp/cassette/README.md +24 -0
- package/src/adapters/hotcrp/cassette/get-noauth.json +10 -0
- package/src/adapters/hotcrp/cassette/get-paper.json +39 -0
- package/src/adapters/hotcrp/cassette/post-dot.json +21 -0
- package/src/adapters/hotcrp/cassette/post-ok.json +12 -0
- package/src/adapters/hotcrp/index.ts +11 -0
- package/src/adapters/hotcrp/portal.io.ts +130 -0
- package/src/adapters/hotcrp/portal.test.ts +503 -0
- package/src/adapters/hotcrp/response.ts +181 -0
- package/src/cli.test.ts +109 -0
- package/src/cli.ts +140 -21
- package/src/domain/result.test.ts +9 -1
- package/src/domain/result.ts +5 -0
- package/src/domain/submission.test.ts +45 -0
- package/src/domain/submission.ts +141 -0
- package/src/domain/venue-name.test.ts +115 -0
- package/src/domain/venue-name.ts +129 -0
- package/src/folder-venue-rule.test.ts +182 -0
- package/src/folder-venue-rule.ts +141 -0
- package/src/init.test.ts +22 -0
- package/src/init.ts +9 -2
- package/src/new-paper.harness.mjs +11 -9
- package/src/new-paper.test.ts +129 -8
- package/src/new-paper.ts +38 -7
- package/src/paper-settings.test.ts +12 -1
- package/src/paper-settings.ts +25 -5
- package/src/parse-args.test.ts +19 -0
- package/src/ports/submission-portal.ts +26 -0
- package/src/presets.test.ts +22 -0
- package/src/presets.ts +46 -3
- package/src/rule-cases.test.ts +19 -0
- package/src/rules-config.ts +1 -1
- package/src/submission.test.ts +496 -0
- package/src/submission.ts +311 -0
- package/src/tex-requirements.ts +8 -0
- package/src/tex-venue-rules.test.ts +1 -1
- package/src/tex-venue-rules.ts +12 -25
- package/src/types.ts +13 -0
package/src/presets.ts
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* tex union — a child never removes a package its parent needs
|
|
20
20
|
* format per key, the child wins; `kinds` by kind name, a child's kind replaces that kind
|
|
21
21
|
* rules per rule id, the child wins
|
|
22
|
-
* template the child wins; so does `name`, and `required_sections`, `register
|
|
22
|
+
* template the child wins; so does `name`, and `required_sections`, `register`, `talk` and
|
|
23
|
+
* `portal` (each whole)
|
|
23
24
|
* aliases union, with every `name` — what the venue is called along the chain
|
|
24
25
|
*
|
|
25
26
|
* ── THE LABEL ─────────────────────────────────────────────────────────────────────
|
|
@@ -42,7 +43,7 @@ import {
|
|
|
42
43
|
type VenueFormat,
|
|
43
44
|
} from "./tex-requirements.ts";
|
|
44
45
|
import { callerPath } from "./caller-path.ts";
|
|
45
|
-
import { err, ok, type Result } from "./domain/result.ts";
|
|
46
|
+
import { err, isOk, ok, type Result } from "./domain/result.ts";
|
|
46
47
|
import type { Files } from "./ports/files.ts";
|
|
47
48
|
import {
|
|
48
49
|
readPaperSettings,
|
|
@@ -53,6 +54,7 @@ import { CONFIG_FILE } from "#lib/paper-config";
|
|
|
53
54
|
import { messageOf } from "./domain/text.ts";
|
|
54
55
|
import type { RegisterAnchor } from "./domain/register.ts";
|
|
55
56
|
import type { VenueTalk } from "./domain/talk.ts";
|
|
57
|
+
import type { VenuePortal } from "./domain/submission.ts";
|
|
56
58
|
|
|
57
59
|
/** The prefix of a shipped preset's spec. */
|
|
58
60
|
export const SHIPPED_PREFIX = "paperlint:";
|
|
@@ -95,6 +97,8 @@ export interface Preset {
|
|
|
95
97
|
readonly registerAnchors: readonly RegisterAnchor[];
|
|
96
98
|
/** What the venue asks a presenter to send (`talk/*`); null when no file of the chain says. */
|
|
97
99
|
readonly talk: VenueTalk | null;
|
|
100
|
+
/** Where the venue takes submissions (`paperlint submission`); null when no file of the chain says. */
|
|
101
|
+
readonly portal: VenuePortal | null;
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
/** Why a spec does not resolve. */
|
|
@@ -227,6 +231,7 @@ function merged(
|
|
|
227
231
|
rules: {},
|
|
228
232
|
registerAnchors: [],
|
|
229
233
|
talk: null,
|
|
234
|
+
portal: null,
|
|
230
235
|
};
|
|
231
236
|
const m = rootFirst.reduce(
|
|
232
237
|
(acc, { preset: p, file }) => ({
|
|
@@ -246,6 +251,7 @@ function merged(
|
|
|
246
251
|
rules: { ...acc.rules, ...p.rules },
|
|
247
252
|
registerAnchors: p.registerAnchors ?? acc.registerAnchors,
|
|
248
253
|
talk: p.talk ?? acc.talk,
|
|
254
|
+
portal: p.portal ?? acc.portal,
|
|
249
255
|
}),
|
|
250
256
|
base,
|
|
251
257
|
);
|
|
@@ -280,11 +286,48 @@ export function shippedPresets(venuesDir: string): string[] {
|
|
|
280
286
|
return venueNames(venuesDir);
|
|
281
287
|
}
|
|
282
288
|
|
|
289
|
+
/** The spec a paper writes in `extends` for the shipped preset `name`: `paperlint:<name>`. */
|
|
290
|
+
export const shippedSpec = (name: string): string => `${SHIPPED_PREFIX}${name}`;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Every shipped preset that resolves, each read the way a paper extending it would read it. The one
|
|
294
|
+
* piece that reads the presets directory. One that does not resolve is left out: `pdf/profile`
|
|
295
|
+
* owns that.
|
|
296
|
+
*/
|
|
297
|
+
export function resolveShipped(deps: PresetDeps): readonly Preset[] {
|
|
298
|
+
const from = join(deps.venuesDir, CONFIG_FILE);
|
|
299
|
+
return shippedPresets(deps.venuesDir)
|
|
300
|
+
.map((name) => resolvePreset(shippedSpec(name), from, deps))
|
|
301
|
+
.filter(isOk)
|
|
302
|
+
.map((r) => r.value);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A shipped venue as a name knows it, and the files of its chain: what tells it from a paper's own. */
|
|
306
|
+
export type ShippedVenue = Pick<Preset, "label" | "aliases" | "chain">;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A resolved preset as a name knows it: its label, its aliases, its chain. Pure. (Not Remeda's
|
|
310
|
+
* `pick`: the app layer may not import it — `boundaries/dependencies`, `APP_EXTERNALS`.)
|
|
311
|
+
*/
|
|
312
|
+
export const venueOf = ({ label, aliases, chain }: Preset): ShippedVenue => ({
|
|
313
|
+
label,
|
|
314
|
+
aliases,
|
|
315
|
+
chain,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* THE ONE SOURCE OF VENUE NAMES: every shipped venue's label and `aliases` (with each `name` along
|
|
320
|
+
* its chain). Read by `paperlint new`, which refuses a folder name naming one, and through
|
|
321
|
+
* `otherVenues` by `tex/venue-leftover` and `paper/folder-venue-leftover`.
|
|
322
|
+
*/
|
|
323
|
+
export const shippedVenueNames = (deps: PresetDeps): readonly ShippedVenue[] =>
|
|
324
|
+
resolveShipped(deps).map(venueOf);
|
|
325
|
+
|
|
283
326
|
/** One line for a problem, naming what to change. Pure. */
|
|
284
327
|
export function presetProblemText(p: PresetProblem): string {
|
|
285
328
|
switch (p.kind) {
|
|
286
329
|
case "unsupported":
|
|
287
|
-
return `"extends": "${p.spec}" — a preset is \`paperlint:<name>\` (shipped) or a path starting with ./ or ../ (your own); npm presets: not yet supported. Did you mean "${
|
|
330
|
+
return `"extends": "${p.spec}" — a preset is \`paperlint:<name>\` (shipped) or a path starting with ./ or ../ (your own); npm presets: not yet supported. Did you mean "${shippedSpec(p.spec)}"?`;
|
|
288
331
|
case "not-found":
|
|
289
332
|
return `"extends": "${p.spec}" names no preset (${p.file}); shipped presets (${SHIPPED_PREFIX}<name>): ${p.shipped.join(", ")}`;
|
|
290
333
|
case "cycle":
|
package/src/rule-cases.test.ts
CHANGED
|
@@ -389,6 +389,25 @@ const CASES: Readonly<Record<string, RuleCases>> = {
|
|
|
389
389
|
},
|
|
390
390
|
silent: aidc("Text."),
|
|
391
391
|
},
|
|
392
|
+
"paper/folder-venue-leftover": {
|
|
393
|
+
reports: {
|
|
394
|
+
// An AIDC paper still in the folder it had when it targeted AISec.
|
|
395
|
+
tree: {
|
|
396
|
+
"papers/aisec-2026/PIPELINE-STATUS.md": STATUS,
|
|
397
|
+
"papers/aisec-2026/paper.tex": tex("x"),
|
|
398
|
+
"papers/aisec-2026/paperlint.json": AIDC[`${P}/paperlint.json`],
|
|
399
|
+
},
|
|
400
|
+
file: "papers/aisec-2026/PIPELINE-STATUS.md",
|
|
401
|
+
severity: 1,
|
|
402
|
+
line: 1,
|
|
403
|
+
},
|
|
404
|
+
// The folder names the paper's own venue.
|
|
405
|
+
silent: {
|
|
406
|
+
"papers/aidc-2026/PIPELINE-STATUS.md": STATUS,
|
|
407
|
+
"papers/aidc-2026/paper.tex": tex("x"),
|
|
408
|
+
"papers/aidc-2026/paperlint.json": AIDC[`${P}/paperlint.json`],
|
|
409
|
+
},
|
|
410
|
+
},
|
|
392
411
|
"tex/venue-leftover": {
|
|
393
412
|
reports: {
|
|
394
413
|
tree: aidc("First written for AISec."),
|
package/src/rules-config.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* A consumer turns an optional rule on, or changes a built-in rule's severity, by writing what they
|
|
7
7
|
* would write in an ESLint flat config — a list of `{ files, ignores, rules }` blocks:
|
|
8
8
|
*
|
|
9
|
-
* "rules": [ { "files": ["papers/
|
|
9
|
+
* "rules": [ { "files": ["papers/rule-drift/**"],
|
|
10
10
|
* "rules": { "pdf/last-page-balance": ["error", { "tolerancePt": 120 }] } } ]
|
|
11
11
|
*
|
|
12
12
|
* The blocks are appended AFTER paperlint's built-in config, so a later block wins, exactly as in ESLint.
|
|
@@ -0,0 +1,496 @@
|
|
|
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.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "vitest";
|
|
8
|
+
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";
|
|
20
|
+
import { presetsDir } from "./package-dirs.ts";
|
|
21
|
+
import type { SubmissionPortal } from "./ports/submission-portal.ts";
|
|
22
|
+
import {
|
|
23
|
+
failureText,
|
|
24
|
+
runSubmission,
|
|
25
|
+
submissionTarget,
|
|
26
|
+
tokenFor,
|
|
27
|
+
usageProblem,
|
|
28
|
+
type SubmissionArgs,
|
|
29
|
+
} from "./submission.ts";
|
|
30
|
+
|
|
31
|
+
const PAPER = "/w/papers/p";
|
|
32
|
+
const SITE = "http://127.0.0.1:9";
|
|
33
|
+
const PDF = new TextEncoder().encode("%PDF fake");
|
|
34
|
+
const HEX = sha256Hex(PDF);
|
|
35
|
+
|
|
36
|
+
/** The test's files, over the disk for what they do not hold: the shipped presets and their schema. */
|
|
37
|
+
const withPresets = (fs: Files): Files => ({
|
|
38
|
+
...fs,
|
|
39
|
+
isFile: (p) => fs.isFile(p) || nodeFiles.isFile(p),
|
|
40
|
+
readBytes: (p) => fs.readBytes(p) ?? nodeFiles.readBytes(p),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const venue = (portal: unknown): string =>
|
|
44
|
+
JSON.stringify({ extends: "paperlint:aidc", portal });
|
|
45
|
+
const files = (o: {
|
|
46
|
+
readonly portal?: unknown;
|
|
47
|
+
readonly paper?: Readonly<Record<string, unknown>>;
|
|
48
|
+
readonly extra?: Readonly<Record<string, string | Uint8Array>>;
|
|
49
|
+
}) =>
|
|
50
|
+
memoryFiles({
|
|
51
|
+
"/w/package.json": "{}",
|
|
52
|
+
"/w/venue.jsonc": venue(o.portal ?? { kind: "hotcrp", url: `${SITE}/` }),
|
|
53
|
+
[`${PAPER}/paperlint.json`]: JSON.stringify(
|
|
54
|
+
o.paper ?? { extends: "../../venue.jsonc", submission: { id: 7 } },
|
|
55
|
+
),
|
|
56
|
+
[`${PAPER}/paper.pdf`]: PDF,
|
|
57
|
+
...o.extra,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const VIEW: SubmissionView = {
|
|
61
|
+
id: 7,
|
|
62
|
+
status: "submitted",
|
|
63
|
+
title: "A Fake Title",
|
|
64
|
+
paperType: "Regular",
|
|
65
|
+
topics: ["A", "B"],
|
|
66
|
+
abstract: "one two three\nfour",
|
|
67
|
+
pdf: {
|
|
68
|
+
sha256: HEX,
|
|
69
|
+
hash: `sha2-${HEX}`,
|
|
70
|
+
size: PDF.length,
|
|
71
|
+
mimetype: "application/pdf",
|
|
72
|
+
uploadedAt: 0,
|
|
73
|
+
},
|
|
74
|
+
submittedAt: 60,
|
|
75
|
+
modifiedAt: 120,
|
|
76
|
+
messages: [],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
interface Recorded {
|
|
80
|
+
readonly portal: SupportedPortal;
|
|
81
|
+
readonly token: string;
|
|
82
|
+
readonly calls: unknown[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const fakePortal = (answers: {
|
|
86
|
+
readonly show?: Result<SubmissionView, PortalFailure>;
|
|
87
|
+
readonly update?: Result<UpdateOutcome, PortalFailure>;
|
|
88
|
+
}) => {
|
|
89
|
+
const seen: Recorded[] = [];
|
|
90
|
+
const portalFor = (
|
|
91
|
+
portal: SupportedPortal,
|
|
92
|
+
token: string,
|
|
93
|
+
): SubmissionPortal => {
|
|
94
|
+
const r: Recorded = { portal, token, calls: [] };
|
|
95
|
+
seen.push(r);
|
|
96
|
+
return {
|
|
97
|
+
show: (id) => {
|
|
98
|
+
r.calls.push(["show", id]);
|
|
99
|
+
return Promise.resolve(answers.show ?? ok(VIEW));
|
|
100
|
+
},
|
|
101
|
+
update: (id, change: SubmissionChange, o) => {
|
|
102
|
+
r.calls.push(["update", id, change, o]);
|
|
103
|
+
return Promise.resolve(
|
|
104
|
+
answers.update ??
|
|
105
|
+
ok({
|
|
106
|
+
httpStatus: 200,
|
|
107
|
+
valid: true,
|
|
108
|
+
changes: ["submission"],
|
|
109
|
+
messages: [],
|
|
110
|
+
dryRun: !o.save,
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
return { seen, portalFor };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const ARGS: SubmissionArgs = {
|
|
120
|
+
sub: "show",
|
|
121
|
+
paperDir: "papers/p",
|
|
122
|
+
extra: [],
|
|
123
|
+
pdf: null,
|
|
124
|
+
abstract: null,
|
|
125
|
+
submit: false,
|
|
126
|
+
save: false,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
async function run(
|
|
130
|
+
a: Partial<SubmissionArgs>,
|
|
131
|
+
o: {
|
|
132
|
+
readonly files?: Files;
|
|
133
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
134
|
+
readonly portal?: ReturnType<typeof fakePortal>;
|
|
135
|
+
} = {},
|
|
136
|
+
) {
|
|
137
|
+
const out: string[] = [];
|
|
138
|
+
const errs: string[] = [];
|
|
139
|
+
const portal = o.portal ?? fakePortal({});
|
|
140
|
+
const fs = withPresets(o.files ?? files({}));
|
|
141
|
+
const code = await runSubmission(
|
|
142
|
+
{ ...ARGS, ...a },
|
|
143
|
+
{
|
|
144
|
+
files: fs,
|
|
145
|
+
presets: { files: fs, venuesDir: presetsDir() },
|
|
146
|
+
cwd: "/w",
|
|
147
|
+
env: (n) => (o.env ?? { HOTCRP_TOKEN: "hct_secret" })[n],
|
|
148
|
+
portalFor: portal.portalFor,
|
|
149
|
+
log: (l) => out.push(l),
|
|
150
|
+
err: (l) => errs.push(l),
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
return { code, out, errs, seen: portal.seen };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
test("show: the portal from the preset, the id from the paper, the token from HOTCRP_TOKEN", async () => {
|
|
157
|
+
const r = await run({});
|
|
158
|
+
assert.equal(r.code, 0);
|
|
159
|
+
assert.deepEqual(r.errs, []);
|
|
160
|
+
assert.deepEqual(r.seen, [
|
|
161
|
+
{
|
|
162
|
+
portal: { kind: "hotcrp", url: SITE },
|
|
163
|
+
token: "hct_secret",
|
|
164
|
+
calls: [["show", 7]],
|
|
165
|
+
},
|
|
166
|
+
]);
|
|
167
|
+
assert.deepEqual(r.out, [
|
|
168
|
+
`submission 7 on ${SITE} (hotcrp)`,
|
|
169
|
+
"status submitted, submitted 1970-01-01T00:01:00.000Z",
|
|
170
|
+
"title A Fake Title",
|
|
171
|
+
"paper type Regular",
|
|
172
|
+
"topics A; B",
|
|
173
|
+
"abstract 4 words",
|
|
174
|
+
`portal PDF sha2-${HEX}, ${String(PDF.length)} bytes, uploaded 1970-01-01T00:00:00.000Z`,
|
|
175
|
+
`local PDF papers/p/paper.pdf sha2-${HEX}`,
|
|
176
|
+
"match yes — the portal holds this build",
|
|
177
|
+
]);
|
|
178
|
+
// Guards: the token is an input to the adapter and nothing printed carries it.
|
|
179
|
+
assert.doesNotMatch([...r.out, ...r.errs].join("\n"), /hct_secret/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("show: a different PDF on the portal is reported as NO, and still exits 0", async () => {
|
|
183
|
+
const other = {
|
|
184
|
+
...VIEW,
|
|
185
|
+
pdf: VIEW.pdf && { ...VIEW.pdf, sha256: sha256Hex(new Uint8Array([1])) },
|
|
186
|
+
};
|
|
187
|
+
const r = await run({}, { portal: fakePortal({ show: ok(other) }) });
|
|
188
|
+
assert.equal(r.code, 0);
|
|
189
|
+
assert.equal(
|
|
190
|
+
r.out.at(-1),
|
|
191
|
+
"match NO — the portal holds a different PDF than the local one",
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("show: no PDF on the portal, a hash that is not sha256, no local PDF, portal messages", async () => {
|
|
196
|
+
const bare: SubmissionView = {
|
|
197
|
+
...VIEW,
|
|
198
|
+
title: null,
|
|
199
|
+
paperType: null,
|
|
200
|
+
topics: [],
|
|
201
|
+
abstract: null,
|
|
202
|
+
pdf: null,
|
|
203
|
+
submittedAt: null,
|
|
204
|
+
messages: [
|
|
205
|
+
{ message: "Not yet submitted", field: "status", status: 1 },
|
|
206
|
+
{ message: "Draft", field: null, status: null },
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
const r1 = await run({}, { portal: fakePortal({ show: ok(bare) }) });
|
|
210
|
+
assert.deepEqual(r1.out.slice(1), [
|
|
211
|
+
"status submitted",
|
|
212
|
+
"title —",
|
|
213
|
+
"paper type —",
|
|
214
|
+
"topics —",
|
|
215
|
+
"abstract —",
|
|
216
|
+
"portal PDF none",
|
|
217
|
+
`local PDF papers/p/paper.pdf sha2-${HEX}`,
|
|
218
|
+
"match NO — the portal holds no PDF",
|
|
219
|
+
" portal says: status: Not yet submitted",
|
|
220
|
+
" portal says: Draft",
|
|
221
|
+
]);
|
|
222
|
+
const sha1 = {
|
|
223
|
+
...VIEW,
|
|
224
|
+
pdf: {
|
|
225
|
+
hash: "sha1-abc",
|
|
226
|
+
sha256: null,
|
|
227
|
+
size: null,
|
|
228
|
+
mimetype: null,
|
|
229
|
+
uploadedAt: null,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
const r2 = await run({}, { portal: fakePortal({ show: ok(sha1) }) });
|
|
233
|
+
assert.equal(r2.out[6], "portal PDF sha1-abc, ? bytes, uploaded —");
|
|
234
|
+
assert.equal(
|
|
235
|
+
r2.out[8],
|
|
236
|
+
"match unknown — the portal names a sha1 hash, not a sha256",
|
|
237
|
+
);
|
|
238
|
+
const r4 = await run({ pdf: "/elsewhere/x.pdf" });
|
|
239
|
+
assert.equal(r4.out[7], "local PDF /elsewhere/x.pdf (missing)");
|
|
240
|
+
const r3 = await run({ pdf: "missing.pdf" });
|
|
241
|
+
assert.equal(r3.out[7], "local PDF missing.pdf (missing)");
|
|
242
|
+
assert.equal(r3.out[8], "match unknown — no local PDF to compare");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("🔴 the portal's refusal is printed with its own message, exit 1", async () => {
|
|
246
|
+
const refused = err<PortalFailure>({
|
|
247
|
+
kind: "refused",
|
|
248
|
+
httpStatus: 401,
|
|
249
|
+
messages: [{ message: "Missing credentials", field: null, status: 2 }],
|
|
250
|
+
});
|
|
251
|
+
const r = await run({}, { portal: fakePortal({ show: refused }) });
|
|
252
|
+
assert.equal(r.code, 1);
|
|
253
|
+
assert.deepEqual(r.errs, [
|
|
254
|
+
"the portal refused (HTTP 401): Missing credentials",
|
|
255
|
+
]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("failureText: each failure in words", () => {
|
|
259
|
+
assert.equal(
|
|
260
|
+
failureText({ kind: "refused", httpStatus: 500, messages: [] }),
|
|
261
|
+
"the portal refused (HTTP 500)",
|
|
262
|
+
);
|
|
263
|
+
assert.equal(
|
|
264
|
+
failureText({ kind: "unreachable", detail: "fetch failed" }),
|
|
265
|
+
"the portal cannot be reached: fetch failed",
|
|
266
|
+
);
|
|
267
|
+
assert.equal(
|
|
268
|
+
failureText({
|
|
269
|
+
kind: "malformed",
|
|
270
|
+
httpStatus: 200,
|
|
271
|
+
detail: "the answer is not JSON (text/html)",
|
|
272
|
+
}),
|
|
273
|
+
"the portal answered HTTP 200, but not as its API does: the answer is not JSON (text/html)",
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("update: a dry run by default — the paper's PDF sent, nothing saved, exit 0", async () => {
|
|
278
|
+
const r = await run({ sub: "update" });
|
|
279
|
+
assert.equal(r.code, 0);
|
|
280
|
+
assert.deepEqual(r.seen[0]?.calls, [
|
|
281
|
+
[
|
|
282
|
+
"update",
|
|
283
|
+
7,
|
|
284
|
+
{ pdf: { name: "paper.pdf", bytes: PDF }, abstract: null, submit: false },
|
|
285
|
+
{ save: false },
|
|
286
|
+
],
|
|
287
|
+
]);
|
|
288
|
+
assert.deepEqual(r.out, [
|
|
289
|
+
"dry run — the portal checked the change and kept nothing; --save sends it for real",
|
|
290
|
+
"HTTP 200",
|
|
291
|
+
"valid yes",
|
|
292
|
+
"changes submission",
|
|
293
|
+
`local PDF papers/p/paper.pdf sha2-${HEX}`,
|
|
294
|
+
]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("update --save --submit --abstract --pdf: all of it sent, and said to be saved", async () => {
|
|
298
|
+
const fs = files({
|
|
299
|
+
extra: { "/w/abs.txt": " The abstract.\n", "/w/other.pdf": PDF },
|
|
300
|
+
});
|
|
301
|
+
const r = await run(
|
|
302
|
+
{
|
|
303
|
+
sub: "update",
|
|
304
|
+
save: true,
|
|
305
|
+
submit: true,
|
|
306
|
+
abstract: "abs.txt",
|
|
307
|
+
pdf: "other.pdf",
|
|
308
|
+
},
|
|
309
|
+
{ files: fs },
|
|
310
|
+
);
|
|
311
|
+
assert.equal(r.code, 0);
|
|
312
|
+
assert.deepEqual(r.seen[0]?.calls, [
|
|
313
|
+
[
|
|
314
|
+
"update",
|
|
315
|
+
7,
|
|
316
|
+
{
|
|
317
|
+
pdf: { name: "other.pdf", bytes: PDF },
|
|
318
|
+
abstract: "The abstract.",
|
|
319
|
+
submit: true,
|
|
320
|
+
},
|
|
321
|
+
{ save: true },
|
|
322
|
+
],
|
|
323
|
+
]);
|
|
324
|
+
assert.equal(r.out[0], "saved");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("update: valid:false, or a non-2xx status, exits 1 with the portal's messages", async () => {
|
|
328
|
+
const invalid = ok<UpdateOutcome>({
|
|
329
|
+
httpStatus: 200,
|
|
330
|
+
valid: false,
|
|
331
|
+
changes: [],
|
|
332
|
+
messages: [{ message: "Bad PDF", field: "submission", status: 2 }],
|
|
333
|
+
dryRun: true,
|
|
334
|
+
});
|
|
335
|
+
const r1 = await run(
|
|
336
|
+
{ sub: "update" },
|
|
337
|
+
{ portal: fakePortal({ update: invalid }) },
|
|
338
|
+
);
|
|
339
|
+
assert.equal(r1.code, 1);
|
|
340
|
+
assert.deepEqual(r1.out.slice(2), [
|
|
341
|
+
"valid NO",
|
|
342
|
+
"changes —",
|
|
343
|
+
`local PDF papers/p/paper.pdf sha2-${HEX}`,
|
|
344
|
+
" portal says: submission: Bad PDF",
|
|
345
|
+
]);
|
|
346
|
+
const status = ok<UpdateOutcome>({
|
|
347
|
+
httpStatus: 400,
|
|
348
|
+
valid: true,
|
|
349
|
+
changes: [],
|
|
350
|
+
messages: [],
|
|
351
|
+
dryRun: false,
|
|
352
|
+
});
|
|
353
|
+
assert.equal(
|
|
354
|
+
(await run({ sub: "update" }, { portal: fakePortal({ update: status }) }))
|
|
355
|
+
.code,
|
|
356
|
+
1,
|
|
357
|
+
);
|
|
358
|
+
const unreachable = err<PortalFailure>({
|
|
359
|
+
kind: "unreachable",
|
|
360
|
+
detail: "down",
|
|
361
|
+
});
|
|
362
|
+
const r3 = await run(
|
|
363
|
+
{ sub: "update" },
|
|
364
|
+
{ portal: fakePortal({ update: unreachable }) },
|
|
365
|
+
);
|
|
366
|
+
assert.equal(r3.code, 1);
|
|
367
|
+
assert.deepEqual(r3.errs, ["the portal cannot be reached: down"]);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("update --save that the portal withheld says so", async () => {
|
|
371
|
+
const withheld = ok<UpdateOutcome>({
|
|
372
|
+
httpStatus: 200,
|
|
373
|
+
valid: true,
|
|
374
|
+
changes: [],
|
|
375
|
+
messages: [],
|
|
376
|
+
dryRun: true,
|
|
377
|
+
});
|
|
378
|
+
const r = await run(
|
|
379
|
+
{ sub: "update", save: true },
|
|
380
|
+
{ portal: fakePortal({ update: withheld }) },
|
|
381
|
+
);
|
|
382
|
+
assert.equal(r.out[0], "SAVE WITHHELD by the portal — nothing changed");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("update: no PDF to send, or no abstract file, is refused before the portal is asked", async () => {
|
|
386
|
+
const r1 = await run({ sub: "update", pdf: "nope.pdf" });
|
|
387
|
+
assert.equal(r1.code, 2);
|
|
388
|
+
assert.deepEqual(r1.errs, [
|
|
389
|
+
"no PDF at nope.pdf — build the paper, or name one with --pdf",
|
|
390
|
+
]);
|
|
391
|
+
assert.deepEqual(r1.seen[0]?.calls, []);
|
|
392
|
+
const r2 = await run({ sub: "update", abstract: "nope.txt" });
|
|
393
|
+
assert.equal(r2.code, 2);
|
|
394
|
+
assert.deepEqual(r2.errs, ["--abstract: no file at nope.txt"]);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("🔴 no token: the variable is named, with where to make one — and nothing is called", async () => {
|
|
398
|
+
const r = await run({}, { env: {} });
|
|
399
|
+
assert.equal(r.code, 2);
|
|
400
|
+
assert.deepEqual(r.seen, []);
|
|
401
|
+
assert.match(
|
|
402
|
+
r.errs[0] ?? "",
|
|
403
|
+
/^HOTCRP_TOKEN is not set — .*Account settings → Developer/,
|
|
404
|
+
);
|
|
405
|
+
assert.deepEqual(
|
|
406
|
+
tokenFor({ kind: "hotcrp", url: SITE }, () => " "),
|
|
407
|
+
{
|
|
408
|
+
ok: false,
|
|
409
|
+
error: r.errs[0],
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("🔴 the file and key to set: no portal in the preset, no submission in the paper, no preset", () => {
|
|
415
|
+
const deps = (fs: Files) => ({
|
|
416
|
+
files: withPresets(fs),
|
|
417
|
+
venuesDir: presetsDir(),
|
|
418
|
+
});
|
|
419
|
+
const noPortal = memoryFiles({
|
|
420
|
+
"/w/package.json": "{}",
|
|
421
|
+
[`${PAPER}/paperlint.json`]: JSON.stringify({
|
|
422
|
+
extends: "paperlint:ieee-conference",
|
|
423
|
+
submission: { id: 7 },
|
|
424
|
+
}),
|
|
425
|
+
});
|
|
426
|
+
const t1 = submissionTarget(PAPER, deps(noPortal));
|
|
427
|
+
assert.match(
|
|
428
|
+
!t1.ok ? t1.error : "",
|
|
429
|
+
/^the venue preset declares no "portal" \(read: .*ieee-conference\.jsonc\) — add "portal": \{ "kind": "hotcrp"/,
|
|
430
|
+
);
|
|
431
|
+
const noId = files({ paper: { extends: "../../venue.jsonc" } });
|
|
432
|
+
assert.deepEqual(submissionTarget(PAPER, deps(noId)), {
|
|
433
|
+
ok: false,
|
|
434
|
+
error: `${PAPER}/paperlint.json: no "submission" — add "submission": { "id": <the submission number on ${SITE}/> }`,
|
|
435
|
+
});
|
|
436
|
+
const noExtends = files({ paper: { submission: { id: 7 } } });
|
|
437
|
+
const t3 = submissionTarget(PAPER, deps(noExtends));
|
|
438
|
+
assert.match(!t3.ok ? t3.error : "", /paperlint\.json: no "extends"/);
|
|
439
|
+
const broken = files({
|
|
440
|
+
paper: { extends: "../../venue.jsonc", submission: { id: "7" } },
|
|
441
|
+
});
|
|
442
|
+
const t4 = submissionTarget(PAPER, deps(broken));
|
|
443
|
+
assert.match(!t4.ok ? t4.error : "", /"submission" must be \{ "id"/);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("🔴 an unknown portal kind is refused: upload by hand", async () => {
|
|
447
|
+
const r = await run(
|
|
448
|
+
{},
|
|
449
|
+
{ files: files({ portal: { kind: "easychair", url: "https://x.test" } }) },
|
|
450
|
+
);
|
|
451
|
+
assert.equal(r.code, 2);
|
|
452
|
+
assert.deepEqual(r.errs, [
|
|
453
|
+
`portal kind "easychair" is not supported; upload by hand`,
|
|
454
|
+
]);
|
|
455
|
+
assert.deepEqual(r.seen, []);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("the shipped aidc preset declares its HotCRP portal", () => {
|
|
459
|
+
const fs = memoryFiles({
|
|
460
|
+
"/w/package.json": "{}",
|
|
461
|
+
[`${PAPER}/paperlint.json`]: JSON.stringify({
|
|
462
|
+
extends: "paperlint:aidc",
|
|
463
|
+
submission: { id: 7 },
|
|
464
|
+
}),
|
|
465
|
+
});
|
|
466
|
+
const t = submissionTarget(PAPER, {
|
|
467
|
+
files: withPresets(fs),
|
|
468
|
+
venuesDir: presetsDir(),
|
|
469
|
+
});
|
|
470
|
+
assert.equal(t.ok && t.value.portal.kind, "hotcrp");
|
|
471
|
+
assert.match(t.ok ? t.value.portal.url : "", /^https:\/\//);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("usage: refused before anything is read, exit 2", async () => {
|
|
475
|
+
const r = await run({ sub: "upload" });
|
|
476
|
+
assert.equal(r.code, 2);
|
|
477
|
+
assert.deepEqual(r.seen, []);
|
|
478
|
+
assert.match(r.errs[0] ?? "", /got `upload`/);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("usage: a subcommand is required, one paper folder, update-only flags refused on show", () => {
|
|
482
|
+
assert.match(
|
|
483
|
+
usageProblem({ ...ARGS, sub: undefined }) ?? "",
|
|
484
|
+
/needs a subcommand: show or update — got ``/,
|
|
485
|
+
);
|
|
486
|
+
assert.match(usageProblem({ ...ARGS, sub: "upload" }) ?? "", /got `upload`/);
|
|
487
|
+
assert.equal(
|
|
488
|
+
usageProblem({ ...ARGS, extra: ["x"] }),
|
|
489
|
+
"`submission show` takes one paper folder — got also `x`",
|
|
490
|
+
);
|
|
491
|
+
assert.equal(
|
|
492
|
+
usageProblem({ ...ARGS, save: true, submit: true, abstract: "a" }),
|
|
493
|
+
"--abstract, --submit, --save: only `submission update` changes anything; `show` reads",
|
|
494
|
+
);
|
|
495
|
+
assert.equal(usageProblem({ ...ARGS, sub: "update", save: true }), null);
|
|
496
|
+
});
|