| 1 | package core_test |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "amadan.net/oficina/slides/internal/slides/core" |
| 11 | ) |
| 12 | |
| 13 | func newRuntime(t *testing.T) (context.Context, *core.Runtime) { |
| 14 | t.Helper() |
| 15 | ctx := context.Background() |
| 16 | rt, err := core.New(ctx) |
| 17 | if err != nil { |
| 18 | t.Fatalf("core.New: %v", err) |
| 19 | } |
| 20 | t.Cleanup(func() { rt.Close(ctx) }) |
| 21 | return ctx, rt |
| 22 | } |
| 23 | |
| 24 | func newDoc(t *testing.T, ctx context.Context, rt *core.Runtime, guid string, client uint64) *core.Handle { |
| 25 | t.Helper() |
| 26 | h, err := rt.NewDoc(ctx, guid, client) |
| 27 | if err != nil { |
| 28 | t.Fatalf("NewDoc(%q): %v", guid, err) |
| 29 | } |
| 30 | t.Cleanup(func() { h.Close(ctx) }) |
| 31 | return h |
| 32 | } |
| 33 | |
| 34 | const addSlide = `[ |
| 35 | {"op":"set_meta","title":"Host test"}, |
| 36 | {"op":"add_slide","id":"aaaaaaaaaaaa","layout":"flow","template":"title"}, |
| 37 | {"op":"add_block","slide":"aaaaaaaaaaaa","id":"bbbbbbbbbbbb","type":"heading", |
| 38 | "props":{"level":1},"region":"heading","markdown":"Hello **world**"} |
| 39 | ]` |
| 40 | |
| 41 | // TestOpsProduceUpdateAndView: an op batch yields a non-empty update, and |
| 42 | // the view afterwards carries what the batch wrote. |
| 43 | func TestOpsProduceUpdateAndView(t *testing.T) { |
| 44 | ctx, rt := newRuntime(t) |
| 45 | h := newDoc(t, ctx, rt, "ops-test", 1) |
| 46 | |
| 47 | update, err := h.Ops(ctx, []byte(addSlide)) |
| 48 | if err != nil { |
| 49 | t.Fatalf("Ops: %v", err) |
| 50 | } |
| 51 | if len(update) == 0 { |
| 52 | t.Fatal("Ops returned an empty update") |
| 53 | } |
| 54 | |
| 55 | view, err := h.JSON(ctx) |
| 56 | if err != nil { |
| 57 | t.Fatalf("JSON: %v", err) |
| 58 | } |
| 59 | if !strings.Contains(string(view), `"id":"aaaaaaaaaaaa"`) { |
| 60 | t.Errorf("view lacks the slide id: %s", view) |
| 61 | } |
| 62 | var parsed struct { |
| 63 | Meta struct { |
| 64 | Title string `json:"title"` |
| 65 | } `json:"meta"` |
| 66 | Slides []struct { |
| 67 | ID string `json:"id"` |
| 68 | Blocks []struct { |
| 69 | Text [][]struct { |
| 70 | T string `json:"t"` |
| 71 | B bool `json:"b"` |
| 72 | } `json:"text"` |
| 73 | } `json:"blocks"` |
| 74 | } `json:"slides"` |
| 75 | } |
| 76 | if err := json.Unmarshal(view, &parsed); err != nil { |
| 77 | t.Fatalf("view is not JSON: %v\n%s", err, view) |
| 78 | } |
| 79 | if parsed.Meta.Title != "Host test" || len(parsed.Slides) != 1 || parsed.Slides[0].ID != "aaaaaaaaaaaa" { |
| 80 | t.Errorf("unexpected view: %s", view) |
| 81 | } |
| 82 | runs := parsed.Slides[0].Blocks[0].Text[0] |
| 83 | if len(runs) != 2 || runs[0].T != "Hello " || runs[1].T != "world" || !runs[1].B { |
| 84 | t.Errorf("unexpected runs: %+v", runs) |
| 85 | } |
| 86 | |
| 87 | // The update the batch produced is a real Yjs update: a fresh |
| 88 | // document fed only that reads the same. |
| 89 | other := newDoc(t, ctx, rt, "ops-test-other", 2) |
| 90 | if err := other.Apply(ctx, update); err != nil { |
| 91 | t.Fatalf("Apply(update from Ops): %v", err) |
| 92 | } |
| 93 | otherView, err := other.JSON(ctx) |
| 94 | if err != nil { |
| 95 | t.Fatalf("JSON other: %v", err) |
| 96 | } |
| 97 | if string(otherView) != string(view) { |
| 98 | t.Errorf("views differ after applying the ops update:\n%s\n%s", view, otherView) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | // TestApplyStateRoundtrip: State encodes the whole document as one |
| 103 | // update; applying it to an empty document reproduces the view. |
| 104 | func TestApplyStateRoundtrip(t *testing.T) { |
| 105 | ctx, rt := newRuntime(t) |
| 106 | a := newDoc(t, ctx, rt, "state-a", 1) |
| 107 | if _, err := a.Ops(ctx, []byte(addSlide)); err != nil { |
| 108 | t.Fatalf("Ops: %v", err) |
| 109 | } |
| 110 | state, err := a.State(ctx) |
| 111 | if err != nil { |
| 112 | t.Fatalf("State: %v", err) |
| 113 | } |
| 114 | if len(state) == 0 { |
| 115 | t.Fatal("State returned no bytes") |
| 116 | } |
| 117 | |
| 118 | b := newDoc(t, ctx, rt, "state-b", 2) |
| 119 | if err := b.Apply(ctx, state); err != nil { |
| 120 | t.Fatalf("Apply(state): %v", err) |
| 121 | } |
| 122 | av, err := a.JSON(ctx) |
| 123 | if err != nil { |
| 124 | t.Fatalf("JSON a: %v", err) |
| 125 | } |
| 126 | bv, err := b.JSON(ctx) |
| 127 | if err != nil { |
| 128 | t.Fatalf("JSON b: %v", err) |
| 129 | } |
| 130 | if string(av) != string(bv) { |
| 131 | t.Errorf("views differ:\n%s\n%s", av, bv) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // TestRejectedOpsSurfaceMessage: a refused batch comes back as a |
| 136 | // *RejectedError carrying the core's message, applies nothing, and |
| 137 | // leaves the instance usable. |
| 138 | func TestRejectedOpsSurfaceMessage(t *testing.T) { |
| 139 | ctx, rt := newRuntime(t) |
| 140 | h := newDoc(t, ctx, rt, "reject-test", 1) |
| 141 | before, err := h.JSON(ctx) |
| 142 | if err != nil { |
| 143 | t.Fatalf("JSON: %v", err) |
| 144 | } |
| 145 | |
| 146 | _, err = h.Ops(ctx, []byte(`[{"op":"add_slide","id":"x","layout":"flow"},{"op":"frobnicate"}]`)) |
| 147 | if err == nil { |
| 148 | t.Fatal("expected an error for an unknown op") |
| 149 | } |
| 150 | var rejected *core.RejectedError |
| 151 | if !errors.As(err, &rejected) { |
| 152 | t.Fatalf("error is not a *RejectedError: %T %v", err, err) |
| 153 | } |
| 154 | if !core.Rejected(err) { |
| 155 | t.Error("Rejected(err) is false") |
| 156 | } |
| 157 | if !strings.Contains(err.Error(), "frobnicate") { |
| 158 | t.Errorf("message does not name the op: %v", err) |
| 159 | } |
| 160 | if core.Recovered(err) { |
| 161 | t.Error("a clean refusal must not read as a recovery") |
| 162 | } |
| 163 | |
| 164 | after, err := h.JSON(ctx) |
| 165 | if err != nil { |
| 166 | t.Fatalf("JSON after: %v", err) |
| 167 | } |
| 168 | if string(after) != string(before) { |
| 169 | t.Errorf("a refused batch changed the document:\n%s\n%s", before, after) |
| 170 | } |
| 171 | |
| 172 | // Malformed JSON and garbage bytes are refusals too, not traps. |
| 173 | for _, bad := range []string{`not json`, `{"op":"set_meta"}`, "\xff\xfe"} { |
| 174 | if _, err := h.Ops(ctx, []byte(bad)); !core.Rejected(err) { |
| 175 | t.Errorf("Ops(%q): want a RejectedError, got %v", bad, err) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // A garbage update is a clean rejection as well. |
| 180 | if err := h.Apply(ctx, []byte{0xff, 0xff, 0xff, 0xff}); !core.Rejected(err) { |
| 181 | t.Errorf("Apply(garbage): want a RejectedError, got %v", err) |
| 182 | } |
| 183 | |
| 184 | // Still usable. |
| 185 | if _, err := h.Ops(ctx, []byte(addSlide)); err != nil { |
| 186 | t.Fatalf("instance unusable after refusals: %v", err) |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | // TestAnOpPastTheMemoryLimitIsAnErrorNotACrash: the runtime caps one |
| 191 | // document's linear memory (defaultMemoryPages), and a document that |
| 192 | // asks for more than the cap fails the way every other guest failure |
| 193 | // does — an error out of the call, the process untouched. |
| 194 | // |
| 195 | // The cap is dropped to 2 MiB here rather than writing a quarter of a |
| 196 | // gibibyte of text to reach the real one: what is under test is that |
| 197 | // the limit is armed at all and that hitting it is an error, and both |
| 198 | // are the same fact at any ceiling. The same op at the default limit is |
| 199 | // run afterwards, so this cannot pass because the op itself is bad. |
| 200 | func TestAnOpPastTheMemoryLimitIsAnErrorNotACrash(t *testing.T) { |
| 201 | ctx := context.Background() |
| 202 | big := `[{"op":"set_meta","title":"` + strings.Repeat("x", 200_000) + `"}]` |
| 203 | |
| 204 | tight, err := core.New(ctx, core.WithMemoryLimitPages(32)) // 2 MiB |
| 205 | if err != nil { |
| 206 | t.Fatalf("core.New with a 32-page limit: %v", err) |
| 207 | } |
| 208 | defer tight.Close(ctx) |
| 209 | h, err := tight.NewDoc(ctx, "tight", 1) |
| 210 | if err != nil { |
| 211 | t.Fatalf("NewDoc under a 32-page limit: %v", err) |
| 212 | } |
| 213 | defer h.Close(ctx) |
| 214 | if _, err := h.Ops(ctx, []byte(big)); err == nil { |
| 215 | t.Fatal("an op past the memory limit succeeded; the limit is not armed") |
| 216 | } else if core.Rejected(err) { |
| 217 | t.Errorf("running out of memory reads as a refusal of the ops: %v", err) |
| 218 | } else { |
| 219 | t.Logf("over the limit: %v", err) |
| 220 | } |
| 221 | |
| 222 | // The same batch, at the real ceiling, is ordinary work. |
| 223 | _, rt := newRuntime(t) |
| 224 | roomy := newDoc(t, ctx, rt, "roomy", 1) |
| 225 | if _, err := roomy.Ops(ctx, []byte(big)); err != nil { |
| 226 | t.Fatalf("the same op at the default limit: %v", err) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | // TestABIVersion: NewDoc instantiates the embedded module and checks its |
| 231 | // reported ABI version — this is the end-to-end proof that the module in |
| 232 | // testdata and this host agree (core.New alone never reaches the check). |
| 233 | func TestABIVersion(t *testing.T) { |
| 234 | ctx, rt := newRuntime(t) |
| 235 | if core.ABIVersion != 2 { |
| 236 | t.Errorf("ABIVersion = %d, want 2", core.ABIVersion) |
| 237 | } |
| 238 | newDoc(t, ctx, rt, "abi-test", 1) |
| 239 | } |
| 240 | |