From b90e4bf3639a561bdf84a0f884b7e10a18232df0 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 28 Sep 2026 14:19:57 +0000 Subject: [PATCH 1/3] test: pin JS/Go whitespace parity for channel name trimming (#99 follow-up) normalizeName() in public/channel-proposals.js trims with JS's native String.prototype.trim(), which disagrees with Go's strings.TrimSpace (used by internal/channelregistry.NormalizeName) on two codepoints: U+FEFF (trimmed by JS, not by Go) and U+0085 NEL (trimmed by Go, not by JS). Add failing-first coverage for both mismatches, plus a Go test that pins the full unicode.IsSpace rune enumeration so a drift in either the JS mirror or the Go stdlib's White_Space table is caught. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Vxw6Ez4BQJo7w99BQqxnji --- internal/channelregistry/name_test.go | 55 ++++++++++++++++++++++ test-channel-proposals.js | 67 +++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/internal/channelregistry/name_test.go b/internal/channelregistry/name_test.go index efb29142b..3a9f25f6b 100644 --- a/internal/channelregistry/name_test.go +++ b/internal/channelregistry/name_test.go @@ -2,8 +2,10 @@ package channelregistry import ( "errors" + "reflect" "strings" "testing" + "unicode" ) func TestNormalizeNameAcceptsAndPreservesCase(t *testing.T) { @@ -133,6 +135,59 @@ func TestNormalizeNameTrimsTrailingLineSeparator(t *testing.T) { } } +// TestGoSpaceCodepointsParity pins the exact set of runes unicode.IsSpace +// (and therefore strings.TrimSpace, which NormalizeName uses) treats as +// whitespace. public/channel-proposals.js keeps its own copy of this same +// list, GO_SPACE_CODEPOINTS, to trim leading/trailing whitespace the same +// way the server does (#99 follow-up: the frontend previously used JS's +// native String.prototype.trim(), which disagrees with TrimSpace on U+FEFF +// and U+0085). If the Go standard library's White_Space table ever changes, +// or the JS list drifts from this one, this test fails — regenerate the want +// slice (and the JS copy) with: +// +// for r := rune(0); r <= 0x10FFFF; r++ { if unicode.IsSpace(r) { fmt.Printf("0x%04X, ", r) } } +func TestGoSpaceCodepointsParity(t *testing.T) { + want := []rune{ + 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x0020, 0x0085, 0x00A0, + 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, + 0x2007, 0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, + } + var got []rune + for r := rune(0); r <= 0x10FFFF; r++ { + if unicode.IsSpace(r) { + got = append(got, r) + } + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unicode.IsSpace enumeration changed:\n got = %#v\n want = %#v\nupdate this test AND GO_SPACE_CODEPOINTS in public/channel-proposals.js together", got, want) + } +} + +func TestNormalizeNameTrimsU0085LikeGoTrimSpace(t *testing.T) { + // U+0085 (NEL) is unicode.IsSpace but not JS's native trim() set; make + // sure the Go side (the authority) does trim and accept it, matching the + // new JS behavior added for parity. + for _, in := range []string{"test\u0085", "\u0085test", "\u0085test\u0085"} { + got, err := NormalizeName(in) + if err != nil || got != "#test" { + t.Errorf("NormalizeName(%q) = %q, %v; want \"#test\", nil", in, got, err) + } + } +} + +func TestNormalizeNameRejectsLeadingTrailingFEFF(t *testing.T) { + // U+FEFF (BOM/ZWNBSP) is JS's native trim() set but not unicode.IsSpace; + // TrimSpace leaves it in place, so it hits the invisible-format check. + // Built with string(rune(0xFEFF)) rather than a source-code escape so the + // byte order mark can't end up as literal source bytes. + bom := string(rune(0xFEFF)) + for _, in := range []string{bom + "test", "test" + bom, bom + bom + "test" + bom} { + if _, err := NormalizeName(in); !errors.Is(err, ErrNameInvisible) { + t.Errorf("NormalizeName(%q) error = %v; want ErrNameInvisible", in, err) + } + } +} + func TestNormalizeNameAllowsEmojiJoinersAndVariationSelectors(t *testing.T) { for _, in := range []string{ "#\U0001f3f3\ufe0f\u200d\U0001f308", // rainbow flag: VS16 + ZWJ diff --git a/test-channel-proposals.js b/test-channel-proposals.js index 25c91d2ad..2d46badf4 100644 --- a/test-channel-proposals.js +++ b/test-channel-proposals.js @@ -619,6 +619,73 @@ test('normalizeName keeps emoji with ZWJ and variation selectors', () => { } }); +// \u2500\u2500 Trim parity with internal/channelregistry.NormalizeName (#99 follow-up) \u2500\u2500 +// JS's native String.prototype.trim() and Go's strings.TrimSpace do not agree +// on which runes are whitespace: trim() also strips U+FEFF (BOM), and does +// not strip U+0085 (NEL) \u2014 TrimSpace is the other way around. normalizeName +// must trim exactly what the server trims, so the two sides reject/accept +// the same inputs. The pinned list below is re-enumerated independently in +// internal/channelregistry/name_test.go (TestGoSpaceCodepointsParity) via +// unicode.IsSpace over the full rune range, so a drift on either side (a Go +// stdlib Unicode table update, or an edit to one list but not the other) +// fails a test. +const GO_SPACE_CODEPOINTS = [ + 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x0020, 0x0085, 0x00A0, + 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, + 0x2007, 0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000 +]; + +test('GO_SPACE_CODEPOINTS matches the pinned Go unicode.IsSpace enumeration', () => { + // CP.GO_SPACE_CODEPOINTS is an Array from the vm context's own realm, so + // compare values (JSON round-trip), not object/constructor identity. + assert.deepStrictEqual(JSON.parse(JSON.stringify(CP.GO_SPACE_CODEPOINTS)), GO_SPACE_CODEPOINTS); +}); + +test('normalizeName rejects a leading/trailing U+FEFF as invisible instead of silently trimming it', () => { + // Go's TrimSpace does NOT strip U+FEFF, so the server sees it, decides it + // is Cf, and rejects with ErrNameInvisible. The frontend must match: not + // trim it away and submit a name the user never typed. + for (const input of ['\uFEFFtest', 'test\uFEFF', '\uFEFF\uFEFFtest\uFEFF']) { + const r = CP.normalizeName(input); + assert.ok(r.error && !r.name, `${JSON.stringify(input)} should be rejected, got ${JSON.stringify(r)}`); + assert.match(r.error, /invisible/, JSON.stringify(input)); + } +}); + +test('normalizeName trims a leading/trailing U+0085 (NEL) and accepts the result', () => { + // Go's TrimSpace DOES strip U+0085 (unicode.IsSpace), so the server sees + // "#test" and accepts it. JS's native trim() does not touch U+0085, which + // used to leave it in CONTROL_RE range and get the frontend to reject a + // name the server would have accepted. + for (const input of ['test\u0085', '\u0085test', '\u0085test\u0085']) { + const r = CP.normalizeName(input); + assert.strictEqual(r.error, undefined, `${JSON.stringify(input)}: ${r.error}`); + assert.strictEqual(r.name, '#test', JSON.stringify(input)); + } +}); + +test('normalizeName trims every codepoint Go treats as whitespace, and only those', () => { + for (const cp of GO_SPACE_CODEPOINTS) { + const c = String.fromCodePoint(cp); + const input = c + 'test' + c; + const r = CP.normalizeName(input); + assert.strictEqual(r.error, undefined, `U+${cp.toString(16).toUpperCase()}: ${r.error}`); + assert.strictEqual(r.name, '#test', `U+${cp.toString(16).toUpperCase()}`); + } + // trimGoSpace itself: no more, no less than the pinned list. + assert.strictEqual(CP.trimGoSpace(' \t\u3000hello\u3000\t '), 'hello'); + assert.strictEqual(CP.trimGoSpace('\uFEFFhello\uFEFF'), '\uFEFFhello\uFEFF', 'U+FEFF must not be trimmed'); + assert.strictEqual(CP.trimGoSpace('\u0085hello\u0085'), 'hello', 'U+0085 must be trimmed'); +}); + +test('normalizeName still rejects U+FEFF / U+200B in the middle of a name (unchanged behavior)', () => { + for (const input of ['#mid\uFEFFdle', '#mid\u200Bdle']) { + const r = CP.normalizeName(input); + assert.ok(r.error && !r.name, `${JSON.stringify(input)} should be rejected, got ${JSON.stringify(r)}`); + assert.match(r.error, /invisible/, JSON.stringify(input)); + } +}); + // ── Remove confirmation escaping (mutant M21) ──────────────────────────── // The dialog is built with the DOM API (no HTML strings with data), so the // name must arrive as text in the title and as a plain attribute value on From 8171c0a6b89322b318ffafe397a24309ed4b46b8 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 28 Sep 2026 14:20:08 +0000 Subject: [PATCH 2/3] fix(channel-proposals): trim channel names like Go's strings.TrimSpace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeName() called String(raw).trim() before the invisible-format check, so JS's native trim rules decided what counted as leading/ trailing whitespace. That set differs from Go's unicode.IsSpace (what strings.TrimSpace, and therefore internal/channelregistry.NormalizeName, use): - U+FEFF (BOM): JS's trim() strips it, Go's does not. A name typed as "mychannel" showed and submitted as "#mychannel" in the UI, then the server rejected it with ErrNameInvisible (400) — a confusing rejection for a name the user never saw accepted. - U+0085 (NEL): Go's TrimSpace strips it, JS's trim() does not. A trailing NEL made the frontend reject the name as a control character, while the server would have trimmed it and accepted "#mychannel". Replace the .trim() call with trimGoSpace(), which trims exactly the codepoint set enumerated from Go's unicode.IsSpace (pinned in GO_SPACE_CODEPOINTS and cross-checked against a matching Go test in internal/channelregistry/name_test.go). The server's NormalizeName is unchanged and remains authoritative; this only aligns the frontend's pre-submit validation/preview with what the server will actually do. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Vxw6Ez4BQJo7w99BQqxnji --- public/channel-proposals.js | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/public/channel-proposals.js b/public/channel-proposals.js index 79c7c4e31..4137bbbee 100644 --- a/public/channel-proposals.js +++ b/public/channel-proposals.js @@ -40,8 +40,35 @@ return encodeURIComponent(s).replace(/%[0-9A-F]{2}/gi, 'x').length; } + // The exact set of runes Go's unicode.IsSpace treats as whitespace (and + // therefore what strings.TrimSpace trims), enumerated from the Go standard + // library rather than guessed — not the same set as JS's native + // String.prototype.trim(), which also strips U+FEFF (BOM) and does not + // strip U+0085 (NEL). internal/channelregistry/name_test.go re-enumerates + // the same rune range with unicode.IsSpace and pins it to this same list, + // so a change on either side fails a test. Regenerate with: + // for r := rune(0); r <= 0x10FFFF; r++ { if unicode.IsSpace(r) { ... } } + var GO_SPACE_CODEPOINTS = [ + 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x0020, 0x0085, 0x00A0, + 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, + 0x2007, 0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000 + ]; + var GO_SPACE_RE = (function () { + var chars = GO_SPACE_CODEPOINTS.map(function (cp) { + return '\\u' + ('000' + cp.toString(16).toUpperCase()).slice(-4); + }).join(''); + return new RegExp('^[' + chars + ']+|[' + chars + ']+$', 'g'); + })(); + + // Mirrors Go's strings.TrimSpace exactly (trims only GO_SPACE_CODEPOINTS + // from both ends), unlike JS's native String.prototype.trim() — see + // GO_SPACE_CODEPOINTS above for why the sets differ. + function trimGoSpace(s) { + return s.replace(GO_SPACE_RE, ''); + } + function normalizeName(raw) { - var s = String(raw == null ? '' : raw).trim(); + var s = trimGoSpace(String(raw == null ? '' : raw)); if (s.charAt(0) !== '#') s = '#' + s; var body = s.slice(1); if (!body) return { error: 'Enter a channel name.' }; @@ -662,6 +689,8 @@ MAX_NAME_BYTES: MAX_NAME_BYTES, MAX_POLL_ATTEMPTS: MAX_POLL_ATTEMPTS, FILTERS: FILTERS, + GO_SPACE_CODEPOINTS: GO_SPACE_CODEPOINTS, + trimGoSpace: trimGoSpace, normalizeName: normalizeName, mergeApprovedChannels: mergeApprovedChannels, pollDelay: pollDelay, From ab31ed5d1123ad19e80bffd3c1c07b509bb8a32b Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 28 Sep 2026 14:26:28 +0000 Subject: [PATCH 3/3] fix: comment/heading corrections from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test-channel-proposals.js: fix a section heading and a comment that had literal ─/— escape text instead of the real ── and — characters used elsewhere in the file. - name_test.go (TestGoSpaceCodepointsParity) and the GO_SPACE_CODEPOINTS comment in channel-proposals.js: correct an inaccurate claim that a drift in the JS list would fail the Go test. The Go test only catches a change in Go's own unicode.IsSpace/White_Space table; the JS test only catches drift in its own copy. The two lists are kept in sync by hand. No logic changes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Vxw6Ez4BQJo7w99BQqxnji --- internal/channelregistry/name_test.go | 7 +++++-- public/channel-proposals.js | 9 ++++++--- test-channel-proposals.js | 17 ++++++++++------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/internal/channelregistry/name_test.go b/internal/channelregistry/name_test.go index 3a9f25f6b..143bdfbdf 100644 --- a/internal/channelregistry/name_test.go +++ b/internal/channelregistry/name_test.go @@ -141,8 +141,11 @@ func TestNormalizeNameTrimsTrailingLineSeparator(t *testing.T) { // list, GO_SPACE_CODEPOINTS, to trim leading/trailing whitespace the same // way the server does (#99 follow-up: the frontend previously used JS's // native String.prototype.trim(), which disagrees with TrimSpace on U+FEFF -// and U+0085). If the Go standard library's White_Space table ever changes, -// or the JS list drifts from this one, this test fails — regenerate the want +// and U+0085). This test only catches a change in Go's own Unicode +// White_Space table against the want slice below; it cannot see the JS +// list, so JS-side drift is caught only by the matching test in +// test-channel-proposals.js. The two lists are kept in sync by hand — +// update both together. If the Go stdlib table changes, regenerate the want // slice (and the JS copy) with: // // for r := rune(0); r <= 0x10FFFF; r++ { if unicode.IsSpace(r) { fmt.Printf("0x%04X, ", r) } } diff --git a/public/channel-proposals.js b/public/channel-proposals.js index 4137bbbee..5f49499e1 100644 --- a/public/channel-proposals.js +++ b/public/channel-proposals.js @@ -44,9 +44,12 @@ // therefore what strings.TrimSpace trims), enumerated from the Go standard // library rather than guessed — not the same set as JS's native // String.prototype.trim(), which also strips U+FEFF (BOM) and does not - // strip U+0085 (NEL). internal/channelregistry/name_test.go re-enumerates - // the same rune range with unicode.IsSpace and pins it to this same list, - // so a change on either side fails a test. Regenerate with: + // strip U+0085 (NEL). internal/channelregistry/name_test.go + // (TestGoSpaceCodepointsParity) independently re-enumerates the same rune + // range with unicode.IsSpace against the same fixed list. The Go test + // catches a change in Go's own Unicode White_Space table; this JS list + // only catches drift from its own JS-side test copy. The two lists are + // kept in sync by hand — update both together. Regenerate with: // for r := rune(0); r <= 0x10FFFF; r++ { if unicode.IsSpace(r) { ... } } var GO_SPACE_CODEPOINTS = [ 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x0020, 0x0085, 0x00A0, diff --git a/test-channel-proposals.js b/test-channel-proposals.js index 2d46badf4..8cc602623 100644 --- a/test-channel-proposals.js +++ b/test-channel-proposals.js @@ -619,16 +619,19 @@ test('normalizeName keeps emoji with ZWJ and variation selectors', () => { } }); -// \u2500\u2500 Trim parity with internal/channelregistry.NormalizeName (#99 follow-up) \u2500\u2500 +// ── Trim parity with internal/channelregistry.NormalizeName (#99 follow-up) ── // JS's native String.prototype.trim() and Go's strings.TrimSpace do not agree // on which runes are whitespace: trim() also strips U+FEFF (BOM), and does -// not strip U+0085 (NEL) \u2014 TrimSpace is the other way around. normalizeName +// not strip U+0085 (NEL) — TrimSpace is the other way around. normalizeName // must trim exactly what the server trims, so the two sides reject/accept -// the same inputs. The pinned list below is re-enumerated independently in -// internal/channelregistry/name_test.go (TestGoSpaceCodepointsParity) via -// unicode.IsSpace over the full rune range, so a drift on either side (a Go -// stdlib Unicode table update, or an edit to one list but not the other) -// fails a test. +// the same inputs. The pinned list below matches the one independently +// enumerated in internal/channelregistry/name_test.go +// (TestGoSpaceCodepointsParity) via unicode.IsSpace over the full rune +// range. The Go test catches a change in Go's own Unicode White_Space +// table; this JS constant only catches a change to this list itself +// (assert.deepStrictEqual below just checks it still matches the runtime +// GO_SPACE_CODEPOINTS shipped in channel-proposals.js). The two lists are +// kept in sync by hand — update both together. const GO_SPACE_CODEPOINTS = [ 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x0020, 0x0085, 0x00A0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006,