diff --git a/package.json b/package.json index 1f4fca25..0efe3a5c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dev": "node bin/pi-pocket.js --cwd .", "check": "tsc --noEmit -p .", "test": "node --test test/*.test.ts", + "coverage": "node --test --experimental-test-coverage --test-coverage-lines=60 test/*.test.ts", "site:publish": "git subtree push --prefix site origin gh-pages", "format": "eslint --fix . && prettier --write .", "format:check": "prettier --check . && eslint .", diff --git a/test/entry-format.test.ts b/test/entry-format.test.ts new file mode 100644 index 00000000..7167d10d --- /dev/null +++ b/test/entry-format.test.ts @@ -0,0 +1,48 @@ +// The names stored entries and the web app depend on: the entry kinds and the markers written into message text. The +// first test is a contract, not a behavior check: it pins values that already-stored data cannot change. The other two +// run the code that reads those names back, so a changed value fails there too. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + ATTACHMENTS_HEADING, + FILE_BLOCK, + FROM_PI_ENTRY, + FROM_PREFIX, + NOTE_ENTRY, + SHELL_ENTRY, +} from "../src/server/entry-format.ts"; + +test("the entry kinds stored data depends on are these names", () => { + assert.equal(NOTE_ENTRY, "pocket.note"); + assert.equal(SHELL_ENTRY, "pocket.shell"); + assert.equal(FROM_PI_ENTRY, "pocket.from-pi"); + assert.equal(FILE_BLOCK, ' { + assert.equal(FROM_PREFIX.exec("[from: Ada] read the file")?.[1], "Ada"); + assert.equal(FROM_PREFIX.exec("[from: Ada] read the file")?.[0], "[from: Ada] "); + assert.equal(FROM_PREFIX.exec("[from: Ada]read the file"), null); + assert.equal(FROM_PREFIX.exec("[from: ] Ada"), null); + assert.equal(FROM_PREFIX.exec("[from: A]da] hi"), null); + assert.equal(FROM_PREFIX.exec("[from: Ada]\nhi"), null); + assert.equal(FROM_PREFIX.exec("hello [from: Ada] there"), null); + assert.equal(FROM_PREFIX.exec(`[from: ${"a".repeat(60)}] hi`)?.[1], "a".repeat(60)); + assert.equal(FROM_PREFIX.exec(`[from: ${"a".repeat(61)}] hi`), null); + assert.equal("[from: Ada] read the file".replace(FROM_PREFIX, ""), "read the file"); +}); + +test("a message with attached files splits into what was written and the file list", () => { + const message = "hello\n\nAttached files (saved on the server):\nnotes.md"; + const parts = message.split(ATTACHMENTS_HEADING); + + assert.equal(parts[0], "hello"); + assert.equal(parts[1], "notes.md"); + assert.equal("hello".split(ATTACHMENTS_HEADING).length, 1); + + const block = '\nthe file\n'; + + assert.equal(block.startsWith(FILE_BLOCK), true); + assert.equal(block.slice(FILE_BLOCK.length, block.indexOf('">')), "notes.md"); +}); diff --git a/test/fence.test.ts b/test/fence.test.ts new file mode 100644 index 00000000..7c20ad38 --- /dev/null +++ b/test/fence.test.ts @@ -0,0 +1,79 @@ +// The fence text from elsewhere goes between tags for Pi: every `<` with a `/` soon after it loses its `<`, whatever +// stands between them, and a `<` that is not a closing tag keeps its own. `quoted` gives one line that cannot close +// its quotes, and `attribute` gives a value that cannot end an attribute. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { attribute, defang, quoted } from "../src/server/fence.ts"; + +const ZERO_WIDTH = "\u200b"; +const VARIATION_SELECTOR = "\uFE0F"; +const FORMAT_CHARACTER = "\u00ad"; + +test("a closing tag loses its <, whatever stands between it and the /", () => { + assert.equal(defang(""), "‹/x>"); + assert.equal(defang("< /x>"), "‹ /x>"); + assert.equal(defang(`<${ZERO_WIDTH}/x>`), `‹${ZERO_WIDTH}/x>`); + assert.equal(defang(`<${VARIATION_SELECTOR}/x>`), `‹${VARIATION_SELECTOR}/x>`); + assert.equal(defang(`<${FORMAT_CHARACTER}/x>`), `‹${FORMAT_CHARACTER}/x>`); + assert.equal(defang("<\t/x>"), "‹\t/x>"); + assert.equal(defang("< \u200b \uFE0F /x>"), "‹ \u200b \uFE0F /x>"); +}); + +test("a closing tag loses its < behind every category of invisible character the class names", () => { + // One representative per category, 81 of them, so the 80-character branch cannot defang it and only the class + // can: a control character, a format character, a lone surrogate, a private-use character, an unassigned code + // point, a variation selector supplement, the braille blank, the Hangul fillers, and the combining grapheme + // joiner. Narrowing the class fails here. + const hides = (character: string, what: string) => { + const invisible = character.repeat(81); + + assert.equal(defang(`<${invisible}/x>`), `‹${invisible}/x>`, what); + }; + + hides("\u0007", "a control character"); + hides("\u00ad", "a format character"); + hides("\uD800", "a lone surrogate"); + hides("\uE000", "a private-use character"); + hides("\u0378", "an unassigned code point"); + hides("\u{E0100}", "a variation selector supplement"); + hides("\u2800", "the braille blank"); + hides("\u3164", "the Hangul filler"); + hides("\uFFA0", "the halfwidth Hangul filler"); + hides("\u034F", "the combining grapheme joiner"); +}); + +test("a closing tag with more than 80 characters between < and / is defanged only by the invisible run", () => { + const long = `<${ZERO_WIDTH.repeat(81)}/x>`; + + assert.equal(defang(long), `‹${ZERO_WIDTH.repeat(81)}/x>`); + assert.equal(defang(`<${ZERO_WIDTH.repeat(80)}/x>`), `‹${ZERO_WIDTH.repeat(80)}/x>`); +}); + +test("a < that is not a closing tag keeps its <", () => { + assert.equal(defang("Vec and
stay"), "Vec and
stay"); + assert.equal(defang("bold"), "bold‹/b>"); +}); + +test("a < with a / within 80 characters loses its <, harmlessly", () => { + assert.equal(defang("a < b/2"), "a ‹ b/2"); + assert.equal(defang("x < y/z"), "x ‹ y/z"); + assert.equal(defang("<"), "<"); + assert.equal(defang(""), ""); +}); + +test("quoted text is one line, without invisible characters, and cannot close its quotes", () => { + assert.equal(quoted("a\u200bb\u202e\u{e0041}c\nd\u2028e\u0085f"), "“abc d e f”"); + assert.equal(quoted('say "hi" now'), '“say "hi" now”'); + assert.equal(quoted(" and and ‹c”"); + assert.equal(quoted(" spaced out "), "“spaced out”"); + assert.equal(quoted("x".repeat(200), 120), `“${"x".repeat(119)}…”`); + assert.equal(quoted(""), "“”"); +}); + +test("an attribute value is one line, without quotes or angle brackets", () => { + assert.equal(attribute('a"bd'), "a b c d"); + assert.equal(attribute("x\r\ny"), "x y"); + assert.equal(attribute(" "), "a"); + assert.equal(attribute("plain"), "plain"); + assert.equal(attribute(""), ""); +});