Files
pad/internal/materialize/materialize_test.go
T
xarmian fa2755e183 feat(materialize): headless op-log materializer bundle, goja runner and worker (TASK-2198 U2) (#1678)
* feat(materialize): headless op-log materializer bundle, goja runner and worker (TASK-2198 U2)

Builds the editor's own JS (Yjs -> y-tiptap -> Tiptap schema -> tiptap-markdown
-> the tab's flush pipeline) into one IIFE, embeds it, and runs it in goja. Not
wired into the server yet (U3 supervisor, U4 write path).

- web/src/lib/collab/materializer/: bundle entry (extension list mirrors
  Editor.svelte's collab branch via the U1 shared modules), op-log replay
  mirroring wsProvider's MESSAGE_SYNC arm, linkedom DOM shim and polyfills
  from the spike, and a vendored patch of prosemirror-markdown
  MarkdownSerializerState.atBlank (quadratic under goja; equivalent).
- web/scripts/build-materializer.mjs (esbuild) -> web/build-materializer/,
  run by `npm run build`; embedded as pad.MaterializerJS.
- internal/materialize: Runner (Go-native atob/btoa/TextEncoder/TextDecoder/
  getRandomValues, schema-version check, ctx interrupt with a clean VM after),
  and RunWorker's length-prefixed JSON protocol behind the hidden
  `pad __materialize-worker` command.
- Parity: vitest drives two real Editor.svelte mounts and compares
  materialize() with the tab's flush on 50 seeded cases; the same cases are
  committed as testdata/corpus.json and replayed in goja (TestCorpusParity).
- CI Go jobs, release tests, Docker and Nix produce the bundle; linkedom and
  esbuild are new devDependencies.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* test(materialize): pin the atBlank patch's equivalence under V8 too (TASK-2198 U2, codex r1)

Codex r1 claimed upstream's /(^|\n)$/ is true for a trailing CR in JS. It is
not (checked in V8: false for "\r" and "a\u2028", as the patch returns),
but the only equivalence test ran under goja and the patch header cited a
V8-side test that did not exist. This adds it, with the line-terminator edge
cases, and it fails when the patch checks CR instead of LF.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* build(nix): vendorHash for goja (TASK-2198 U2)

Taken from the Nix job's recompute on c7fa1931 (specified sha256-iEqv…,
got sha256-P1bQ…), so main does not depend on the heal job after merge.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 02:50:10 -04:00

301 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package materialize
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"math/rand"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/dop251/goja"
pad "github.com/PerpetualSoftware/pad"
"github.com/PerpetualSoftware/pad/internal/collab"
)
// sharedRunner is one Runner over the embedded bundle for the tests that do
// not disturb it. Loading costs about a second.
var (
sharedOnce sync.Once
sharedRunner *Runner
sharedErr error
)
func runner(t *testing.T) *Runner {
t.Helper()
sharedOnce.Do(func() { sharedRunner, sharedErr = New(pad.MaterializerJS) })
if sharedErr != nil {
t.Fatalf("load embedded bundle: %v", sharedErr)
}
return sharedRunner
}
type corpusCase struct {
Name string `json:"name"`
Rows []string `json:"rows"`
LinkIndex []LinkEntry `json:"link_index"`
WorkspaceSlug string `json:"workspace_slug"`
Expected string `json:"expected"`
}
func loadCorpus(t *testing.T) []corpusCase {
t.Helper()
raw, err := os.ReadFile("testdata/corpus.json")
if err != nil {
t.Fatal(err)
}
var cases []corpusCase
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatal(err)
}
if len(cases) == 0 {
t.Fatal("empty corpus")
}
return cases
}
func (c corpusCase) job(t *testing.T, schema string) Job {
t.Helper()
j := Job{SchemaVersion: schema, LinkIndex: c.LinkIndex, WorkspaceSlug: c.WorkspaceSlug}
for _, s := range c.Rows {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
t.Fatal(err)
}
j.Rows = append(j.Rows, b)
}
return j
}
// TestCorpusParity: the embedded bundle, in goja, reproduces the live editor's
// flush output byte for byte. The corpus's `expected` values were produced by
// a real Editor.svelte mount (materializerParity.svelte.test.ts, regenerate
// with PAD_GEN_MATERIALIZE_CORPUS=1), with prosemirror-markdown's UPSTREAM
// atBlank, so this also checks the vendored atBlank patch end to end.
func TestCorpusParity(t *testing.T) {
r := runner(t)
cases := loadCorpus(t)
for _, c := range cases {
got, err := r.Materialize(context.Background(), c.job(t, r.SchemaVersion()))
if err != nil {
t.Errorf("%s: %v", c.Name, err)
continue
}
if got != c.Expected {
t.Errorf("%s: output differs from the live editor's\n--- got ---\n%s\n--- want ---\n%s", c.Name, got, c.Expected)
}
}
t.Logf("%d corpus cases", len(cases))
}
// The bundle's schema version is web/src/lib/collab/schemaVersion.ts's, and
// it must be the server's, or the materializer would accept rows the collab
// room would refuse (or the reverse).
func TestBundleSchemaVersionMatchesCollab(t *testing.T) {
if got := runner(t).SchemaVersion(); got != collab.DefaultSchemaVersion {
t.Fatalf("bundle schema version %q, collab.DefaultSchemaVersion %q", got, collab.DefaultSchemaVersion)
}
}
func TestSchemaVersionMismatchRefused(t *testing.T) {
r := runner(t)
for _, v := range []string{"", r.SchemaVersion() + "0", "2"} {
_, err := r.Materialize(context.Background(), Job{SchemaVersion: v})
if !errors.Is(err, ErrSchemaVersion) {
t.Errorf("schema %q: got %v, want ErrSchemaVersion", v, err)
}
}
// And the matching version is accepted (an empty op-log is an empty doc).
md, err := r.Materialize(context.Background(), Job{SchemaVersion: r.SchemaVersion()})
if err != nil || md != "" {
t.Fatalf("empty job: %q, %v", md, err)
}
}
// TestAtBlankPatched guards the vendored prosemirror-markdown atBlank patch
// (web/src/lib/collab/materializer/atBlank.ts): without it serialization is
// quadratic under goja.
func TestAtBlankPatched(t *testing.T) {
ok, err := runner(t).AtBlankPatched()
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("the embedded bundle's serializer does not run the vendored atBlank patch")
}
}
// TestAtBlankAgreesWithUpstream: the patched atBlank and the saved upstream
// implementation (/(^|\n)$/.test(out)) agree on generated serializer outputs,
// with the characters that could trip an end-of-input test over-represented.
func TestAtBlankAgreesWithUpstream(t *testing.T) {
r := runner(t)
alphabet := []string{"a", " ", "\n", "\r", "\r\n", "
", "
", "\n\n", "$", "^", "\t", "é", "🎉", "\x00", "\\n", "�", "\v", "\f"}
gen := rand.New(rand.NewSource(2198))
outs := []string{"", "\n", "a", "a\n", "\n\n", "a\r", "\r", "a
", "
"}
for i := 0; i < 5000; i++ {
var sb strings.Builder
for n := gen.Intn(12); n > 0; n-- {
sb.WriteString(alphabet[gen.Intn(len(alphabet))])
}
outs = append(outs, sb.String())
}
sawTrue, sawFalse := false, false
r.mu.Lock()
defer r.mu.Unlock()
for _, out := range outs {
v, err := r.call("atBlankPair", out)
if err != nil {
t.Fatal(err)
}
var pair [2]bool
if err := json.Unmarshal([]byte(v.String()), &pair); err != nil {
t.Fatal(err)
}
if pair[0] != pair[1] {
t.Errorf("out=%q: upstream %v, patched %v", out, pair[0], pair[1])
}
sawTrue = sawTrue || pair[0]
sawFalse = sawFalse || !pair[0]
}
if !sawTrue || !sawFalse {
t.Fatalf("generator reached only one answer (true %v, false %v)", sawTrue, sawFalse)
}
}
// replaceMaterialize swaps the bundle function Materialize calls, to drive the
// interrupt machinery with a job that never ends on its own.
func replaceMaterialize(t *testing.T, r *Runner, src string) func() {
t.Helper()
v, err := r.vm.RunString(src)
if err != nil {
t.Fatal(err)
}
f, ok := goja.AssertFunction(v)
if !ok {
t.Fatal("not a function")
}
orig := r.materialize
r.materialize = f
return func() { r.materialize = orig }
}
func TestInterruptOnDeadlineAndReuse(t *testing.T) {
r, err := New(pad.MaterializerJS)
if err != nil {
t.Fatal(err)
}
c := loadCorpus(t)[0]
job := c.job(t, r.SchemaVersion())
restore := replaceMaterialize(t, r, `(function () { for (;;) {} })`)
const deadline = 50 * time.Millisecond
for i := 0; i < 3; i++ {
ctx, cancel := context.WithTimeout(context.Background(), deadline)
start := time.Now()
done := make(chan error, 1)
go func() { _, err := r.Materialize(ctx, job); done <- err }()
var err error
select {
case err = <-done:
case <-time.After(10 * time.Second):
t.Fatal("Materialize did not return 10s after a 50ms deadline: the runtime was not interrupted")
}
took := time.Since(start)
cancel()
if !errors.Is(err, ErrInterrupted) || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("busy loop: got %v, want ErrInterrupted wrapping DeadlineExceeded", err)
}
if took > deadline+time.Second {
t.Fatalf("interrupt took %v after a %v deadline", took, deadline)
}
t.Logf("interrupted after %v (deadline %v)", took, deadline)
}
// Cancellation, not only a deadline.
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(20*time.Millisecond, cancel)
if _, err := r.Materialize(ctx, job); !errors.Is(err, context.Canceled) {
t.Fatalf("cancel: got %v", err)
}
restore()
// The Runner is usable afterwards and still exact.
got, err := r.Materialize(context.Background(), job)
if err != nil {
t.Fatalf("after interrupt: %v", err)
}
if got != c.Expected {
t.Fatalf("after interrupt: output differs from corpus %s", c.Name)
}
}
// A context ending AFTER the job's JavaScript returned, but before the
// watcher is stopped, makes the watcher Interrupt a VM that is no longer
// running. goja keeps that flag set, so without ClearInterrupt the NEXT job
// would be killed on its first instruction. The hook puts the cancel in
// exactly that window.
func TestNoInterruptLeaksIntoNextJob(t *testing.T) {
r, err := New(pad.MaterializerJS)
if err != nil {
t.Fatal(err)
}
c := loadCorpus(t)[0]
job := c.job(t, r.SchemaVersion())
ctx, cancel := context.WithCancel(context.Background())
testHookAfterCall = func() {
cancel()
time.Sleep(50 * time.Millisecond) // let the watcher take ctx.Done
}
got, err := r.Materialize(ctx, job)
testHookAfterCall = nil
if err != nil || got != c.Expected {
t.Fatalf("the job itself completed before the cancel: %v", err)
}
got, err = r.Materialize(context.Background(), job)
if err != nil {
t.Fatalf("next job: %v", err)
}
if got != c.Expected {
t.Fatal("next job output differs")
}
}
func TestExpiredContextDoesNotRun(t *testing.T) {
r := runner(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := r.Materialize(ctx, Job{SchemaVersion: r.SchemaVersion()}); !errors.Is(err, ErrInterrupted) {
t.Fatalf("got %v", err)
}
}
func TestPlaceholderBundleRefused(t *testing.T) {
if _, err := New([]byte("placeholder")); err == nil {
t.Fatal("a non-bundle loaded without error")
}
if _, err := New([]byte("var x = 1;")); err == nil || !strings.Contains(err.Error(), "Materializer") {
t.Fatalf("got %v", err)
}
}
// A malformed frame is skipped and the rest still apply, as in a tab.
func TestMalformedRowSkipped(t *testing.T) {
r := runner(t)
c := loadCorpus(t)[1]
job := c.job(t, r.SchemaVersion())
junk := [][]byte{{}, {0}, {0, 7, 1, 0}, {0, 2, 200}, {1, 2, 3}, {0xff, 0xff, 0xff, 0xff, 0xff}}
job.Rows = append(append(append([][]byte{}, junk...), job.Rows[:len(job.Rows)/2]...), append(junk, job.Rows[len(job.Rows)/2:]...)...)
got, err := r.Materialize(context.Background(), job)
if err != nil {
t.Fatal(err)
}
if got != c.Expected {
t.Fatalf("junk frames changed the output of %s", c.Name)
}
}