// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "bytes" "context" "encoding/json" "errors" "log/slog" "os" "path/filepath" "reflect" "strconv" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/interaction" "git.stormux.org/storm/cthulhu/internal/preferences" "git.stormux.org/storm/cthulhu/internal/speech" "git.stormux.org/storm/cthulhu/internal/tentacle" "git.stormux.org/storm/cthulhu/internal/tentaclewire" sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle" ) // A private protocol fixture, not an installable or bundled tentacle. func reader_probe_extensions() sdk.Extensions { return sdk.Extensions{Pages: []sdk.PreferencePage{{ID: "probe", Title: "Extension probe", Command: "apply", Elements: []sdk.Element{ {ID: "rate", Label: "Probe rate", Kind: sdk.Entry, Required: true, MaxLength: 3}, {ID: "enabled", Label: "Probe enabled", Kind: sdk.CheckBox}, {ID: "choice", Label: "Probe choice", Kind: sdk.List, Items: []sdk.ListItem{{ID: "first", Label: "First choice"}, {ID: "second", Label: "Second choice"}}}, }, Values: map[string]sdk.Value{"rate": {Text: "70"}, "enabled": {}, "choice": {Selected: "first"}}}}, QuickSettings: []sdk.QuickSetting{{ID: "probe", Label: "Probe mode", Command: "apply", Selected: "default", Options: []sdk.ListItem{{ID: "default", Label: "Default"}, {ID: "fast", Label: "Fast"}, {ID: "unavailable", Label: "Unavailable"}}}}} } func TestTentacleReaderExtensionPeer(t *testing.T) { if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "reader-extension-peer" { return } mode := os.Args[len(os.Args)-1] commands := []sdk.Command{{ID: "apply", Title: "Apply probe", Presentation: true, Interaction: true, Reader: true, Handle: func(ctx context.Context, arguments json.RawMessage) (any, error) { var event sdk.ExtensionEvent if err := json.Unmarshal(arguments, &event); err != nil { return nil, err } if mode == "reject" { return nil, errors.New("rejected") } if mode == "crash" { os.Exit(3) } if event.Event == "exit" { os.Exit(3) } if mode == "slow" { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(time.Minute): return nil, nil } } if event.Event == "preferences.open" { if mode == "load-denied" { configuration := event.Reader.DefaultSpeech return sdk.Interaction{Reader: &sdk.ReaderRequest{Speech: &configuration}}, nil } if mode == "load-snapshot" { return sdk.Interaction{Reader: &sdk.ReaderRequest{Snapshot: true, Module: "alpha"}, Next: "receive"}, nil } extensions := event.Reader.Extensions extensions.Pages[0].Elements[1].Label = "Loaded probe enabled" return sdk.Interaction{Reader: &sdk.ReaderRequest{Extensions: &extensions}}, nil } if event.Event == "preferences.apply" { value, err := strconv.ParseFloat(event.Values["rate"].Text, 64) if err != nil { return nil, err } configuration := event.Reader.Speech configuration.DefaultVoice.Rate = &value extensions := event.Reader.Extensions extensions.Pages[0].Values = event.Values return sdk.Interaction{Reader: &sdk.ReaderRequest{Speech: &configuration, Extensions: &extensions}}, nil } if event.Event == "quick-setting" { if event.Selected == "default" { return sdk.Interaction{Reader: &sdk.ReaderRequest{RestoreSpeech: true}}, nil } configuration := event.Reader.Speech rate := 80.0 configuration.DefaultVoice.Rate = &rate if event.Selected == "unavailable" { configuration.Module = "unavailable" } return sdk.Interaction{Reader: &sdk.ReaderRequest{Speech: &configuration}}, nil } if event.Event == "snapshot" { return sdk.Interaction{Reader: &sdk.ReaderRequest{Snapshot: true}, Next: "receive"}, nil } return nil, errors.New("unknown event") }}, {ID: "receive", Title: "Receive probe", Presentation: true, Interaction: true, Reader: true, Handle: func(ctx context.Context, arguments json.RawMessage) (any, error) { var submission sdk.Submission if err := json.Unmarshal(arguments, &submission); err != nil { return nil, err } if submission.Reader == nil || submission.Error != "" { return nil, errors.New("no reader state") } if mode == "load-snapshot" { if len(submission.Reader.Voices) != 1 || submission.Reader.Voices[0].Name != "Example voice" { return nil, errors.New("missing voice catalog") } extensions := submission.Reader.Extensions extensions.Pages[0].Elements[1].Label = "Loaded catalog probe" return sdk.Interaction{Reader: &sdk.ReaderRequest{Extensions: &extensions}}, nil } configuration := submission.Reader.DefaultSpeech rate := 77.0 configuration.DefaultVoice.Rate = &rate return sdk.Interaction{Reader: &sdk.ReaderRequest{Speech: &configuration}}, nil }}} extensions := reader_probe_extensions() if strings.HasPrefix(mode, "load") { extensions.Pages[0].Load = true } if err := sdk.ServeWithExtensions(os.Stdin, os.Stdout, commands, extensions); err != nil { os.Exit(1) } os.Exit(0) } type readerSpeechRecorder struct { values []config.Settings failure string hook func() } func (recorder *readerSpeechRecorder) UpdateConfiguration(ctx context.Context, next config.Settings) error { if recorder.hook != nil { recorder.hook() } if err := ctx.Err(); err != nil { return err } if next.SpeechOutputModule == recorder.failure && recorder.failure != "" { return errors.New("module unavailable") } recorder.values = append(recorder.values, copy_reader_value(next)) return nil } func reader_extension_fixture(t *testing.T, mode string) (*preferencesWorker, *tentacle.Session, *readerSpeechRecorder) { t.Helper() manager, err := tentacle.NewManager(tentacle.DefaultLimits(), nil) if err != nil { t.Fatal(err) } t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() if err := manager.Shutdown(ctx); err != nil { t.Error(err) } }) executable, err := os.Executable() if err != nil { t.Fatal(err) } session, err := manager.Start(t.Context(), tentacle.Spec{ID: "reader-probe", Program: []string{executable, "-test.run=^TestTentacleReaderExtensionPeer$", "--", "reader-extension-peer", mode}, Presentation: true, Interaction: true, ReaderSettings: true}) if err != nil { t.Fatal(err) } select { case <-session.Ready(): case <-time.After(5 * time.Second): t.Fatal("handshake timed out") } if err := session.Activate(); err != nil { state, reason := session.Status() t.Fatal(err, state, reason) } runtime := newTentacleRuntime(slog.New(slog.DiscardHandler), &tentacleBindingRecorder{}, "", "") runtime.output = tentacleOutputFixture(t) runtime.publishCommands([]*tentacle.Session{session}) settings := config.DefaultSettings() settings.Path = filepath.Join(t.TempDir(), "settings.toml") rate := 50.0 settings.DefaultVoice.Rate = &rate recorder := &readerSpeechRecorder{failure: "unavailable"} worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, recorder) worker.tentacles = runtime runtime.readerSettings = worker worker.live = &liveSettings{} worker.live.Store(settings) worker.discover = func(context.Context, string) (speech.Catalog, error) { return speech.Catalog{"alpha": {{Name: "Example voice", Language: "en"}}}, nil } worker.sync_extensions(t.Context()) return worker, session, recorder } func reader_page_request(worker *preferencesWorker, rate string) preferences.Request { page := worker.extension_pages()[0] values := copy_reader_value(page.Values) values["rate"] = interaction.Value{Text: rate} return preferences.Request{Values: preferences.FromSettings(worker.settings), ExtensionPages: []preferences.PageDraft{{Session: page.Session, ID: page.ID, Revision: page.Revision, Values: values}}} } func TestReaderExtensionPageApplyCancelSaveAndReopen(t *testing.T) { worker, session, recorder := reader_extension_fixture(t, "normal") before := copy_reader_value(worker.settings) // Creating or editing a draft has no effect until Apply reaches the owner. request := reader_page_request(worker, "73") if len(recorder.values) != 0 || worker.speechOverride != nil { t.Fatal("draft changed runtime") } request.Save = true response := worker.apply(t.Context(), request) if !response.Accepted { t.Fatal(response.Message) } if len(recorder.values) != 1 || *recorder.values[0].DefaultVoice.Rate != 73 || *worker.live.Load().DefaultVoice.Rate != 73 { t.Fatal("override was not applied") } if !reflect.DeepEqual(worker.settings, before) { t.Fatal("override overwrote base settings") } if worker.extension_pages()[0].Values["rate"].Text != "73" { t.Fatal("draft not committed") } store, _ := config.NewStore(worker.settings.Path) saved, err := store.Load() if err != nil || *saved.DefaultVoice.Rate != 50 { t.Fatal("override was persisted as normal speech", err) } persisted, err := (&tentacle.PageStore{Path: worker.pageStore.Path}).Load() if err != nil || persisted[session.TentacleID()]["probe"]["rate"].Text != "73" { t.Fatal(persisted, err) } // Re-registering after a new session loads only saved page values, not speech. worker.extensions = map[string]*readerExtension{} worker.speechOverride, worker.speechOverrideOwner = nil, "" worker.sync_extensions(t.Context()) if worker.extension_pages()[0].Values["rate"].Text != "73" || worker.speechOverride != nil { t.Fatal("page restoration activated speech") } response = worker.apply(t.Context(), request) if response.Accepted { t.Fatal("accepted a stale revision") } } func TestReaderExtensionQuickNavigationRollbackDefaultAndBaseEdits(t *testing.T) { worker, _, recorder := reader_extension_fixture(t, "normal") var messages []string worker.announce = func(message string) { messages = append(messages, message) } worker.speechSetting = 4 worker.handleSpeechCommand(t.Context(), input.SelectNextSpeechSetting) if messages[len(messages)-1] != "Probe mode Default" { t.Fatal(messages) } worker.handleSpeechCommand(t.Context(), input.IncreaseSpeechSetting) if *worker.live.Load().DefaultVoice.Rate != 80 || *worker.settings.DefaultVoice.Rate != 50 { t.Fatal("quick override corrupted base") } worker.handleSpeechCommand(t.Context(), input.IncreaseSpeechSetting) if messages[len(messages)-1] != "Probe mode was not changed" || worker.quick_settings()[0].setting.Selected != "fast" || *worker.live.Load().DefaultVoice.Rate != 80 { t.Fatal("failed switch committed") } // Built-in changes edit the active temporary override, not the normal voice. worker.quickSettingKey = "" worker.speechSetting = 0 worker.handleSpeechCommand(t.Context(), input.IncreaseSpeechSetting) if *worker.live.Load().DefaultVoice.Rate != 81 || *worker.settings.DefaultVoice.Rate != 50 { t.Fatal("built-in adjustment changed base") } // A core preference edit still targets and saves the ordinary settings. next := worker.settings pitch := 6.0 next.DefaultVoice.AveragePitch = &pitch response := worker.applyNext(t.Context(), next, false, false) if !response.Accepted || *worker.live.Load().DefaultVoice.Rate != 81 { t.Fatal(response.Message) } worker.quickSettingKey = worker.quick_settings()[0].key worker.handleSpeechCommand(t.Context(), input.DecreaseSpeechSetting) if worker.speechOverride != nil || *worker.live.Load().DefaultVoice.Rate != 50 || *worker.live.Load().DefaultVoice.AveragePitch != 6 { t.Fatal("default did not restore edited base") } if len(recorder.values) < 3 { t.Fatal("backend not exercised") } } func TestReaderExtensionRejectionCrashAndFailedApplyLeaveDraftUncommitted(t *testing.T) { for _, mode := range []string{"reject", "crash", "normal"} { t.Run(mode, func(t *testing.T) { worker, _, recorder := reader_extension_fixture(t, mode) request := reader_page_request(worker, "74") if mode == "normal" { worker.braille = &recordingBrailleSettings{err: errors.New("braille failed")} request.Values.EnableBraille = !worker.settings.EnableBraille } response := worker.apply(t.Context(), request) if response.Accepted || worker.speechOverride != nil || *worker.live.Load().DefaultVoice.Rate != 50 { t.Fatal("failed apply committed", response.Message) } if mode == "normal" { if len(recorder.values) != 2 || *recorder.values[0].DefaultVoice.Rate != 74 || *recorder.values[1].DefaultVoice.Rate != 50 { t.Fatal("speech not rolled back after later failure", recorder.values) } if worker.extension_pages()[0].Values["rate"].Text != "70" { t.Fatal("page values committed on failure") } } }) } } func TestReaderExtensionRevocationRestoresAndRemovesContributions(t *testing.T) { worker, session, _ := reader_extension_fixture(t, "normal") worker.quickSettingKey = worker.quick_settings()[0].key value := tentaclewire.SpeechFromSettings(worker.settings) rate := 82.0 value.DefaultVoice.Rate = &rate if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil { t.Fatal(err) } session.Disable() worker.sync_extensions(t.Context()) if worker.speechOverride != nil || len(worker.extension_pages()) != 0 || len(worker.quick_settings()) != 0 || *worker.live.Load().DefaultVoice.Rate != 50 { t.Fatal("revoked contributions survived") } if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err == nil { t.Fatal("late effect applied") } message := "" worker.announce = func(text string) { message = text } worker.handleSpeechCommand(t.Context(), input.IncreaseSpeechSetting) if message != "The selected speech setting is no longer available" || worker.settings.DefaultVoice.Family != nil { t.Fatal("a removed quick setting silently adjusted a different setting", message) } } func TestReaderExtensionRevocationDuringBackendPreparation(t *testing.T) { worker, session, recorder := reader_extension_fixture(t, "normal") value := tentaclewire.SpeechFromSettings(worker.settings) rate := 82.0 value.DefaultVoice.Rate = &rate recorder.hook = func() { session.Disable() } _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false) if err == nil || worker.speechOverride != nil || *worker.live.Load().DefaultVoice.Rate != 50 { t.Fatal("revoked update committed", err) } } func TestReaderExtensionSnapshotBoundsAndPreferenceWindowGate(t *testing.T) { worker, session, _ := reader_extension_fixture(t, "normal") worker.discover = func(context.Context, string) (speech.Catalog, error) { voices := make([]speech.Voice, 10000) for index := range voices { voices[index] = speech.Voice{Name: strings.Repeat("Voice", 40), Language: "en"} } return speech.Catalog{"alpha": voices, "beta": nil}, nil } state, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Snapshot: true, Module: "alpha"}, true) if err != nil || !state.CatalogTruncated || len(state.Voices) == 0 || len(state.Modules) != 2 { t.Fatal(state, err) } data, _ := json.Marshal(tentaclewire.Submission{Reader: state}) if len(data) > tentacle.MaxReaderArgumentBytes { t.Fatal("oversized continuation") } value := state.Speech if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, true); err == nil { t.Fatal("changed speech behind preferences draft") } ctx, cancel := context.WithCancel(t.Context()) cancel() if _, err := worker.apply_reader_request(ctx, session, tentaclewire.ReaderRequest{Speech: &value}, false); err == nil { t.Fatal("cancelled request applied") } page := worker.extension_pages()[0] page.Values["rate"] = interaction.Value{Text: "1"} if worker.extension_pages()[0].Values["rate"].Text != "70" { t.Fatal("snapshot aliases page values") } } func TestReaderExtensionSlowCallbackTimesOutAndRetiresSession(t *testing.T) { worker, session, _ := reader_extension_fixture(t, "slow") ctx, cancel := context.WithTimeout(t.Context(), 40*time.Millisecond) defer cancel() response := worker.apply(ctx, reader_page_request(worker, "75")) if response.Accepted || worker.speechOverride != nil { t.Fatal("timeout applied") } waitTentacle(t, func() bool { state, _ := session.Status(); return state != tentacle.Active }) worker.sync_extensions(t.Context()) if len(worker.extension_pages()) != 0 { t.Fatal("timed-out page survived") } } func TestReaderExtensionWorkerMailboxAndProtocolContinuation(t *testing.T) { worker, session, _ := reader_extension_fixture(t, "normal") // The real runtime interaction asks the worker for a snapshot, receives it // through the protocol continuation, and proposes a speech change. if err := worker.Start(t.Context()); err != nil { t.Fatal(err) } t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() if err := worker.Stop(ctx); err != nil { t.Error(err) } }) command := tentacleCommand{session: session, id: "apply", reader: true, interactive: true, presentation: true} raw, _ := json.Marshal(sdk.Interaction{Reader: &sdk.ReaderRequest{Snapshot: true}, Next: "receive"}) worker.tentacles.interact(t.Context(), command, raw) // Query through the same owner mailbox; do not read mutable worker state. state, err := worker.request_reader(t.Context(), session, tentaclewire.ReaderRequest{Snapshot: true}) if err != nil || state.Speech.DefaultVoice.Rate == nil || *state.Speech.DefaultVoice.Rate != 77 { t.Fatal(state, err) } session.Disable() worker.tentacles.signal_extensions() // Sync is owner-serialized by the next request, proving queued late work is rejected. if _, err := worker.request_reader(t.Context(), session, tentaclewire.ReaderRequest{RestoreSpeech: true}); err == nil { t.Fatal("revoked mailbox work accepted") } } func TestReaderExtensionPersistFailureReportsSessionOnly(t *testing.T) { worker, _, _ := reader_extension_fixture(t, "normal") if err := os.WriteFile(worker.pageStore.Path, []byte(`{}`), 0600); err != nil { t.Fatal(err) } request := reader_page_request(worker, "76") request.Save = true request.Close = true response := worker.apply(t.Context(), request) if !response.Accepted || response.Close || !strings.Contains(response.Message, "could not be saved") { t.Fatal(response) } data, _ := os.ReadFile(worker.pageStore.Path) if !bytes.Equal(data, []byte(`{}`)) { t.Fatal("overwrote external changes") } } func TestReaderExtensionApplicationOverlayAndDisabledSpeech(t *testing.T) { worker, session, recorder := reader_extension_fixture(t, "normal") value := tentaclewire.SpeechFromSettings(worker.settings) rate := 83.0 value.DefaultVoice.Rate = &rate if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil { t.Fatal(err) } appSettings := worker.settings appRate := 61.0 appSettings.DefaultVoice.Rate = &appRate appSettings.SpeechVerbosityLevel = 0 worker.appSettings["Example application"] = appSettings worker.switchApplication(t.Context(), "Example application") if *worker.live.Load().DefaultVoice.Rate != 83 || worker.live.Load().SpeechVerbosityLevel != 0 { t.Fatal("application switch lost override or app preferences") } if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{RestoreSpeech: true}, false); err != nil { t.Fatal(err) } if *worker.live.Load().DefaultVoice.Rate != 61 { t.Fatal("did not restore current application's voice") } next := worker.settings next.EnableSpeech = false if err := worker.applyRuntime(t.Context(), next); err != nil { t.Fatal(err) } count := len(recorder.values) if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil { t.Fatal(err) } if len(recorder.values) != count || worker.live.Load().EnableSpeech { t.Fatal("override enabled or connected disabled speech") } next.EnableSpeech = true if err := worker.applyRuntime(t.Context(), next); err != nil { t.Fatal(err) } if len(recorder.values) != count+1 || *recorder.values[len(recorder.values)-1].DefaultVoice.Rate != 83 { t.Fatal("reenabling did not apply pending override") } next.EnableSpeech = false if err := worker.applyRuntime(t.Context(), next); err != nil { t.Fatal(err) } count = len(recorder.values) session.Disable() worker.sync_extensions(t.Context()) if worker.speechOverride != nil || len(recorder.values) != count { t.Fatal("disabled speech required a backend restoration") } } func TestReaderExtensionOwnershipLockAndAtomicContributionUpdate(t *testing.T) { worker, first, _ := reader_extension_fixture(t, "normal") _, second, _ := reader_extension_fixture(t, "other") worker.tentacles.publishCommands([]*tentacle.Session{first, second}) worker.sync_extensions(t.Context()) value := tentaclewire.SpeechFromSettings(worker.settings) rate := 85.0 value.DefaultVoice.Rate = &rate if _, err := worker.apply_reader_request(t.Context(), first, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil { t.Fatal(err) } for _, request := range []tentaclewire.ReaderRequest{{Speech: &value}, {RestoreSpeech: true}} { if _, err := worker.apply_reader_request(t.Context(), second, request, false); err == nil { t.Fatal("another session changed the owner's override") } } extensions := copy_reader_value(worker.extensions[first.Identity()].value) extensions.QuickSettings[0].Label = "Renamed probe" value.Module = "unavailable" if _, err := worker.apply_reader_request(t.Context(), first, tentaclewire.ReaderRequest{Speech: &value, Extensions: &extensions}, false); err == nil { t.Fatal("unavailable module accepted") } if worker.extensions[first.Identity()].value.QuickSettings[0].Label != "Probe mode" { t.Fatal("partial contribution update committed") } worker.tentacles.output.gate.secure.Store(true) if _, err := worker.apply_reader_request(t.Context(), first, tentaclewire.ReaderRequest{RestoreSpeech: true}, false); err == nil { t.Fatal("locked reader accepted request") } worker.tentacles.output.gate.secure.Store(false) worker.tentacles.output.gate.suspended.Store(true) if _, err := worker.apply_reader_request(t.Context(), first, tentaclewire.ReaderRequest{Snapshot: true}, false); err == nil { t.Fatal("suspended reader exposed state") } worker.tentacles.output.gate.suspended.Store(false) } func TestReaderExtensionRestorationFailureRetainsWorkingVoiceAndReports(t *testing.T) { worker, session, recorder := reader_extension_fixture(t, "normal") value := tentaclewire.SpeechFromSettings(worker.settings) value.Module = "alpha" if _, err := worker.apply_reader_request(t.Context(), session, tentaclewire.ReaderRequest{Speech: &value}, false); err != nil { t.Fatal(err) } worker.settings.SpeechOutputModule = "unavailable" message := "" worker.announce = func(text string) { message = text } session.Disable() worker.sync_extensions(t.Context()) if worker.speechOverride == nil || worker.live.Load().SpeechOutputModule != "alpha" || !strings.Contains(message, "could not be restored") { t.Fatal("failed restoration lost working speech", message) } if len(worker.extension_pages()) != 0 || len(worker.quick_settings()) != 0 { t.Fatal("revoked UI survived failed restoration") } recorder.failure = "" worker.sync_extensions(t.Context()) if worker.speechOverride != nil || worker.live.Load().SpeechOutputModule != "unavailable" { t.Fatal("later restoration did not recover") } } func TestReaderExtensionManagedLifecycleRestoresWithoutAnotherCommand(t *testing.T) { for _, ending := range []string{"disable", "remove", "exit", "emergency"} { t.Run(ending, func(t *testing.T) { directory := t.TempDir() runtime, _ := managed_tentacle_fixture(t, filepath.Join(directory, "tentacles.toml"), false) runtime.output = tentacleOutputFixture(t) settings := config.DefaultSettings() settings.Path = filepath.Join(directory, "settings.toml") baseRate := 50.0 settings.DefaultVoice.Rate = &baseRate worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, &readerSpeechRecorder{}) worker.tentacles = runtime runtime.readerSettings = worker worker.live = &liveSettings{} worker.live.Store(settings) worker.discover = func(context.Context, string) (speech.Catalog, error) { return speech.Catalog{}, nil } if err := runtime.Start(t.Context()); err != nil { t.Fatal(err) } if err := worker.Start(t.Context()); err != nil { t.Fatal(err) } t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) defer cancel() if err := worker.Stop(ctx); err != nil { t.Error(err) } }) executable, err := os.Executable() if err != nil { t.Fatal(err) } entry := tentacle.Entry{ID: "managed-reader-probe", Enabled: true, Presentation: true, Interaction: true, ReaderSettings: true, Program: []string{executable, "-test.run=^TestTentacleReaderExtensionPeer$", "--", "reader-extension-peer", "normal"}} manage_tentacle(t, runtime, "add", entry.ID, &entry) sessions := runtime.reader_sessions() if len(sessions) != 1 { t.Fatal("reader session not published", runtime.Snapshot()) } var session *tentacle.Session for _, value := range sessions { session = value } configuration := tentaclewire.SpeechFromSettings(settings) rate := 87.0 configuration.DefaultVoice.Rate = &rate if _, err := worker.request_reader(t.Context(), session, tentaclewire.ReaderRequest{Speech: &configuration}); err != nil { t.Fatal(err) } if *worker.live.Load().DefaultVoice.Rate != 87 { t.Fatal("override not active") } switch ending { case "disable", "remove": manage_tentacle(t, runtime, ending, entry.ID, nil) case "exit": replies, err := session.Invoke(t.Context(), "apply", json.RawMessage(`{"event":"exit"}`)) if err != nil { t.Fatal(err) } if result := <-replies; result.Err == nil { t.Fatal("child did not exit") } case "emergency": runtime.Disable() } // Observe the independent atomic snapshot only. No request or command // is sent to the preferences worker to trigger reconciliation. waitTentacle(t, func() bool { return worker.live.Load().DefaultVoice.Rate != nil && *worker.live.Load().DefaultVoice.Rate == 50 }) }) } } func TestReaderExtensionPageLoadCanInspectCatalogWithoutChangingSpeech(t *testing.T) { for _, mode := range []string{"load", "load-snapshot", "load-denied"} { t.Run(mode, func(t *testing.T) { worker, _, recorder := reader_extension_fixture(t, mode) response, err := worker.initial_response(t.Context()) if err != nil { t.Fatal(err) } if len(recorder.values) != 0 || worker.speechOverride != nil { t.Fatal("opening preferences changed speech") } if len(response.ExtensionPages) != 1 || response.ExtensionPages[0].Values["rate"].Text != "70" { t.Fatal("load discarded page values") } label := response.ExtensionPages[0].Elements[1].Label switch mode { case "load": if label != "Loaded probe enabled" { t.Fatal(label) } case "load-snapshot": if label != "Loaded catalog probe" { t.Fatal("catalog continuation did not refresh page", label) } case "load-denied": if label != "Probe enabled" || !strings.Contains(response.Message, "could not load") { t.Fatal("speech-changing load was not rejected", label, response.Message) } } }) } }