From f179712d744d9813c2980431020278b6b049fa4f Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:12:47 +0000 Subject: [PATCH 1/5] Add failing OOPIF network telemetry regression tests --- server/lib/cdpmonitor/README.md | 22 ++ server/lib/cdpmonitor/chrome_e2e_test.go | 11 +- .../lib/cdpmonitor/network_chrome_e2e_test.go | 274 ++++++++++++++++++ 3 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 server/lib/cdpmonitor/network_chrome_e2e_test.go diff --git a/server/lib/cdpmonitor/README.md b/server/lib/cdpmonitor/README.md index 103806986..e9d95e828 100644 --- a/server/lib/cdpmonitor/README.md +++ b/server/lib/cdpmonitor/README.md @@ -8,6 +8,28 @@ The monitor is the browser-facing layer of the kernel browser logging pipeline. Chrome can restart independently of the monitor. When that happens, `UpstreamProvider` pushes a new DevTools URL and the monitor reconnects automatically, emitting lifecycle events so consumers can track continuity. +## Real-Chromium network regression tests + +From `server/`, with Chromium on `PATH`: + +```sh +KERNEL_CDPMONITOR_CHROME_E2E=1 go test ./lib/cdpmonitor \ + -run '^TestNetworkCaptureAcrossFrames$' -count=1 -v +``` + +The fixture uses local HTTP servers and forces site isolation; it needs no Kernel +credentials or external websites. It checks sent and aborted POSTs from top-level, +same-origin, cross-origin, and nested cross-origin frames, in default and isolated +browser contexts, with frames created before and after the monitor starts. It +verifies browser results and server-received bodies before asserting telemetry +request/response bodies and failure correlation. + +These tests currently fail for cross-origin and nested cross-origin frames because +the collector does not attach their OOPIF targets. Top-level and same-origin controls +pass. They assert the desired behavior, not the current omission, and do not prescribe +an attachment strategy. Without the environment variable they skip, matching the +other real-Chromium tests in this package. + ## Event taxonomy **CDP-derived** (1-to-1 with a CDP notification): `console_log`, `console_error`, `network_request`, `network_response`, `network_loading_failed`, `proxy_error` (classified from a branded 5xx response carrying the `X-Kernel-Proxy-Error` header), `page_tab_opened`, `page_navigation`, `page_dom_content_loaded`, `page_load`, `page_layout_shift`, `page_lcp`. `proxy_error` is an opt-in per-session/per-URL refinement of the raw `network` events: it is only observable while the network category (CDP collector) is running, so it is not a default-on alerting signal. diff --git a/server/lib/cdpmonitor/chrome_e2e_test.go b/server/lib/cdpmonitor/chrome_e2e_test.go index 80054b292..73a1c27fe 100644 --- a/server/lib/cdpmonitor/chrome_e2e_test.go +++ b/server/lib/cdpmonitor/chrome_e2e_test.go @@ -110,13 +110,16 @@ func findChromium(t *testing.T) string { return "" } -func launchChromium(t *testing.T, ctx context.Context, chrome string) string { +func launchChromium(t *testing.T, ctx context.Context, chrome string, extraArgs ...string) string { t.Helper() dir := t.TempDir() - cmd := exec.CommandContext(ctx, chrome, + args := []string{ "--headless=new", "--no-sandbox", "--disable-gpu", - "--remote-debugging-port=0", "--user-data-dir="+dir, "about:blank", - ) + "--remote-debugging-port=0", "--user-data-dir=" + dir, + } + args = append(args, extraArgs...) + args = append(args, "about:blank") + cmd := exec.CommandContext(ctx, chrome, args...) stderr, err := cmd.StderrPipe() require.NoError(t, err) require.NoError(t, cmd.Start()) diff --git a/server/lib/cdpmonitor/network_chrome_e2e_test.go b/server/lib/cdpmonitor/network_chrome_e2e_test.go new file mode 100644 index 000000000..775251a86 --- /dev/null +++ b/server/lib/cdpmonitor/network_chrome_e2e_test.go @@ -0,0 +1,274 @@ +package cdpmonitor + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/events" + "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +// These tests use real cross-site frames rather than injecting attachment events: +// they must pass regardless of how the collector discovers and attaches targets. +// Run with KERNEL_CDPMONITOR_CHROME_E2E=1 and Chromium on PATH. +func TestNetworkCaptureAcrossFrames(t *testing.T) { + if os.Getenv("KERNEL_CDPMONITOR_CHROME_E2E") == "" { + t.Skip("set KERNEL_CDPMONITOR_CHROME_E2E=1 to run real-Chromium network tests") + } + chrome := findChromium(t) + for _, isolated := range []bool{false, true} { + contextName := "default" + if isolated { + contextName = "isolated" + } + for _, existing := range []bool{false, true} { + timing := "new_frames" + if existing { + timing = "existing_frames" + } + t.Run(contextName+"/"+timing, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + type receivedPost struct{ path, body string } + received := make(chan receivedPost, 16) + var origin, crossOrigin string + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + received <- receivedPost{r.URL.RequestURI(), string(body)} + if r.URL.Path == "/abort" { + select { + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + } + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + return + } + w.Header().Set("Content-Type", "text/html") + switch r.URL.Path { + case "/": + fmt.Fprintf(w, ``, crossOrigin) + case "/cross": + fmt.Fprintf(w, ``, origin) + default: + fmt.Fprint(w, "frame") + } + })) + defer stub.Close() + origin = stub.URL + crossOrigin = strings.Replace(origin, "127.0.0.1", "localhost", 1) + + browserWS := launchChromium(t, ctx, chrome, "--site-per-process") + cdp := dialCDP(t, ctx, browserWS) + defer cdp.close() + params := map[string]any{"url": "about:blank"} + if isolated { + raw := cdp.call(t, ctx, "", "Target.createBrowserContext", map[string]any{"disposeOnDetach": true}) + var result struct { + Result struct { + BrowserContextID string `json:"browserContextId"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(raw.raw, &result)) + require.NotEmpty(t, result.Result.BrowserContextID) + params["browserContextId"] = result.Result.BrowserContextID + } + targetID := cdp.call(t, ctx, "", "Target.createTarget", params).targetID(t) + sessionID := cdp.call(t, ctx, "", "Target.attachToTarget", map[string]any{"targetId": targetID, "flatten": true}).sessionID(t) + ec := newEventCollector() + m := New(&staticUpstream{url: browserWS}, ec.publishFn(), 99, discardLogger, nil) + defer m.Stop() + if !existing { + require.NoError(t, m.Start(ctx)) + } + cdp.call(t, ctx, sessionID, "Page.navigate", map[string]any{"url": origin}) + require.Eventually(t, func() bool { + return cdp.evalBool(ctx, sessionID, fmt.Sprintf(`location.href === %q && document.readyState === "complete"`, origin+"/")) + }, 10*time.Second, 50*time.Millisecond, "frame documents did not finish loading") + + // Driver attachment must not substitute for the monitor's own attachment. + crossTarget := findNetworkFrameTarget(t, ctx, cdp, crossOrigin+"/cross") + nestedTarget := findNetworkFrameTarget(t, ctx, cdp, origin+"/nested") + crossSession := cdp.call(t, ctx, "", "Target.attachToTarget", map[string]any{"targetId": crossTarget, "flatten": true}).sessionID(t) + nestedSession := cdp.call(t, ctx, "", "Target.attachToTarget", map[string]any{"targetId": nestedTarget, "flatten": true}).sessionID(t) + if existing { + require.NoError(t, m.Start(ctx)) + } + require.Eventually(t, func() bool { + return cdp.evalBool(ctx, sessionID, "window.__kernelEventInjected === true") + }, 10*time.Second, 50*time.Millisecond, "monitor did not initialize the top-level page") + // Exercise settled documents, not requests racing initial attachment. + time.Sleep(500 * time.Millisecond) + + for _, frame := range []struct { + name, session, target, targetType, origin, realm string + }{ + {"top", sessionID, targetID, "page", origin, "window"}, + {"same_origin", sessionID, targetID, "page", origin, `document.querySelector("#same").contentWindow`}, + {"cross_origin", crossSession, crossTarget, "iframe", crossOrigin, "window"}, + {"nested_cross_origin", nestedSession, nestedTarget, "iframe", origin, "window"}, + } { + for _, aborted := range []bool{false, true} { + outcome := "sent" + if aborted { + outcome = "aborted" + } + t.Run(frame.name+"/"+outcome, func(t *testing.T) { + marker := strings.ReplaceAll(t.Name(), "/", "_") + body, err := json.Marshal(map[string]string{"marker": marker}) + require.NoError(t, err) + path := "/post?marker=" + marker + if aborted { + path = "/abort?marker=" + marker + } + expression := fmt.Sprintf(`(async () => { + const realm = %s; + const controller = new AbortController(); + const timer = %t ? setTimeout(() => controller.abort(), 500) : null; + try { + const response = await realm.fetch(%q, { + method: "POST", headers: {"Content-Type": "application/json"}, + body: %q, signal: controller.signal + }); + return {status: response.status, body: await response.text()}; + } catch (error) { + return {error: error.name}; + } finally { clearTimeout(timer); } + })()`, frame.realm, aborted, path, string(body)) + raw := cdp.call(t, ctx, frame.session, "Runtime.evaluate", map[string]any{ + "expression": expression, "awaitPromise": true, "returnByValue": true, + }) + var result struct { + Error json.RawMessage `json:"error"` + Result struct { + ExceptionDetails json.RawMessage `json:"exceptionDetails"` + Result struct { + Value struct { + Status int `json:"status"` + Body string `json:"body"` + Error string `json:"error"` + } `json:"value"` + } `json:"result"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(raw.raw, &result)) + require.Empty(t, result.Error) + require.Empty(t, result.Result.ExceptionDetails) + value := result.Result.Result.Value + if aborted { + require.Equal(t, "AbortError", value.Error) + } else { + require.Empty(t, value.Error) + require.Equal(t, http.StatusOK, value.Status) + require.JSONEq(t, string(body), value.Body) + } + select { + case request := <-received: + require.Equal(t, path, request.path) + require.JSONEq(t, string(body), request.body) + case <-time.After(time.Second): + t.Fatal("the HTTP server did not receive the POST") + } + + requestEvent := waitForNetworkURL(t, ec, EventNetworkRequest, frame.origin+path) + var request oapi.BrowserNetworkRequestEventData + require.NoError(t, json.Unmarshal(requestEvent.Data, &request)) + require.Equal(t, http.MethodPost, request.Method) + require.NotNil(t, request.PostData) + require.JSONEq(t, string(body), *request.PostData) + require.Equal(t, frame.target, request.TargetId) + require.Equal(t, frame.targetType, string(request.TargetType)) + require.NotEmpty(t, request.SessionId) + require.NotNil(t, request.FrameId) + require.NotEmpty(t, *request.FrameId) + if aborted { + event := waitForNetworkURL(t, ec, EventNetworkLoadingFailed, frame.origin+path) + var failure oapi.BrowserNetworkLoadingFailedEventData + require.NoError(t, json.Unmarshal(event.Data, &failure)) + require.Equal(t, request.RequestId, failure.RequestId) + require.Equal(t, request.SessionId, failure.SessionId) + require.True(t, failure.Canceled) + } else { + event := waitForNetworkURL(t, ec, EventNetworkResponse, frame.origin+path) + var response oapi.BrowserNetworkResponseEventData + require.NoError(t, json.Unmarshal(event.Data, &response)) + require.Equal(t, request.RequestId, response.RequestId) + require.Equal(t, request.SessionId, response.SessionId) + require.EqualValues(t, http.StatusOK, response.Status) + require.NotNil(t, response.Body) + require.JSONEq(t, string(body), *response.Body) + } + }) + } + } + }) + } + } +} + +func findNetworkFrameTarget(t *testing.T, ctx context.Context, cdp *cdpConn, url string) string { + t.Helper() + var targetID string + require.Eventually(t, func() bool { + raw, err := cdp.roundtrip(ctx, "", "Target.getTargets", nil) + if err != nil { + return false + } + var result struct { + Result struct { + TargetInfos []cdpTargetTargetInfo `json:"targetInfos"` + } `json:"result"` + } + if json.Unmarshal(raw, &result) != nil { + return false + } + for _, target := range result.Result.TargetInfos { + if target.URL == url && target.Type == "iframe" { + targetID = target.TargetID + return true + } + } + return false + }, 5*time.Second, 50*time.Millisecond, "expected a real out-of-process iframe target for %s", url) + return targetID +} + +func waitForNetworkURL(t *testing.T, ec *eventCollector, eventType, url string) events.Event { + t.Helper() + var found events.Event + require.Eventually(t, func() bool { + ec.mu.Lock() + defer ec.mu.Unlock() + for _, event := range ec.events { + if event.Type != eventType || event.Category != events.Network { + continue + } + var data struct { + URL string `json:"url"` + } + if json.Unmarshal(event.Data, &data) == nil && data.URL == url { + found = event + return true + } + } + return false + }, 3*time.Second, 25*time.Millisecond, "missing %s telemetry for completed browser operation %s", eventType, url) + return found +} From 731425232ceac2a206b60bf750da8ff156656ce6 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:52:16 +0000 Subject: [PATCH 2/5] Share browser target tracking with telemetry capture --- .github/workflows/server-test.yaml | 6 + server/lib/browsersurface/events.go | 11 +- server/lib/browsersurface/frames.go | 74 +- server/lib/browsersurface/sessions_test.go | 150 +++ server/lib/browsersurface/targets.go | 59 +- server/lib/browsersurface/tracker.go | 115 +- server/lib/browsersurface/tracker_test.go | 19 + server/lib/browsersurface/types.go | 13 + server/lib/cdpmonitor/README.md | 106 +- server/lib/cdpmonitor/cdp_test.go | 2 +- server/lib/cdpmonitor/chrome_e2e_test.go | 8 +- server/lib/cdpmonitor/handlers.go | 46 +- .../cdpmonitor/lifecycle_chrome_e2e_test.go | 113 ++ server/lib/cdpmonitor/monitor.go | 521 ++----- server/lib/cdpmonitor/monitor_test.go | 67 +- .../lib/cdpmonitor/network_chrome_e2e_test.go | 1 + server/lib/cdpmonitor/screenshot.go | 2 +- server/lib/cdpmonitor/session_test.go | 84 ++ server/lib/cdpmonitor/types.go | 7 +- .../lib/cdpmonitor/workers_chrome_e2e_test.go | 148 ++ server/lib/oapi/oapi.go | 1198 +++++++++-------- server/openapi.yaml | 4 +- 22 files changed, 1594 insertions(+), 1160 deletions(-) create mode 100644 server/lib/browsersurface/sessions_test.go create mode 100644 server/lib/cdpmonitor/lifecycle_chrome_e2e_test.go create mode 100644 server/lib/cdpmonitor/session_test.go create mode 100644 server/lib/cdpmonitor/workers_chrome_e2e_test.go diff --git a/.github/workflows/server-test.yaml b/.github/workflows/server-test.yaml index b3bc43e60..236a43bae 100644 --- a/.github/workflows/server-test.yaml +++ b/.github/workflows/server-test.yaml @@ -99,6 +99,12 @@ jobs: cache: true cache-dependency-path: server/go.sum + - name: Run CDP telemetry browser regressions + run: go test -race ./lib/cdpmonitor -run '^(TestNetworkCapture|TestTelemetryConnection)' -count=1 -v + working-directory: server + env: + KERNEL_CDPMONITOR_CHROME_E2E: "1" + - name: Compute short SHA for images id: vars shell: bash diff --git a/server/lib/browsersurface/events.go b/server/lib/browsersurface/events.go index cdea820c5..f4d1466ce 100644 --- a/server/lib/browsersurface/events.go +++ b/server/lib/browsersurface/events.go @@ -2,11 +2,16 @@ package browsersurface import ( "encoding/json" + "strings" "github.com/kernel/kernel-images/server/lib/cdpclient" ) func (t *Tracker) handleProtocolEvent(message cdpclient.Message) { + if !t.trackLocations && strings.HasPrefix(message.Method, "Page.") { + t.publish(Event{Kind: EventProtocol, SessionID: message.SessionID, Message: message}) + return + } switch message.Method { case "Target.targetCreated": var event struct { @@ -16,8 +21,10 @@ func (t *Tracker) handleProtocolEvent(message cdpclient.Message) { switch event.TargetInfo.Type { case "page": t.trackPage(event.TargetInfo, true) - case "iframe": - t.trackFrameTarget(event.TargetInfo) + default: + if t.tracksTarget(event.TargetInfo.Type) { + t.trackNonPageTarget(event.TargetInfo) + } } } case "Target.targetInfoChanged": diff --git a/server/lib/browsersurface/frames.go b/server/lib/browsersurface/frames.go index 6ab150c83..6e3ee1790 100644 --- a/server/lib/browsersurface/frames.go +++ b/server/lib/browsersurface/frames.go @@ -8,7 +8,7 @@ import ( ) func (t *Tracker) addSession(sessionID, parentSessionID string, target targetInfo) { - if target.Type != "page" && target.Type != "iframe" { + if !t.tracksTarget(target.Type) { return } t.stateMu.Lock() @@ -16,6 +16,10 @@ func (t *Tracker) addSession(sessionID, parentSessionID string, target targetInf t.stateMu.Unlock() return } + if target.Type != "page" && parentSessionID != "" && t.sessions[parentSessionID] == nil { + t.stateMu.Unlock() + return + } tabID := t.tabsByTarget[target.TargetID] if tabID == 0 && target.Type == "iframe" && parentSessionID != "" { if parent := t.sessions[parentSessionID]; parent != nil { @@ -33,14 +37,35 @@ func (t *Tracker) addSession(sessionID, parentSessionID string, target targetInf } } ownedParentID := "" - if target.Type == "iframe" { + if target.Type != "page" { ownedParentID = parentSessionID } t.sessions[sessionID] = &session{id: sessionID, parentID: ownedParentID, target: target, tabID: tabID} t.bindSessionsLocked() t.stateMu.Unlock() t.signalChanged() - go t.initializeSession(sessionID) + t.publish(Event{ + Kind: EventSessionAttached, SessionID: sessionID, + Target: SessionTarget{ID: target.TargetID, Type: target.Type, URL: target.URL, Title: target.Title, OpenerID: target.OpenerID}, + }) + if t.tracksTarget("worker") && target.Type != "service_worker" { + go t.attachDedicatedWorkers(sessionID) + } + if t.trackLocations && (target.Type == "page" || target.Type == "iframe") { + go t.initializeSession(sessionID) + } +} + +func (t *Tracker) attachDedicatedWorkers(sessionID string) { + ctx, cancel := context.WithTimeout(t.ctx, sessionInitTimeout) + defer cancel() + _, err := t.protocol.Send(ctx, "Target.setAutoAttach", map[string]any{ + "autoAttach": true, "flatten": true, "waitForDebuggerOnStart": false, + "filter": []map[string]any{{"type": "worker"}}, + }, sessionID) + if err != nil && ctx.Err() == nil && t.SessionExists(sessionID) { + t.logger.Warn("failed to attach dedicated workers", "session_id", sessionID, "err", err) + } } func (t *Tracker) initializeSession(sessionID string) { @@ -177,26 +202,35 @@ func (t *Tracker) upsertFrameLocked(tabID int, info frameInfo) { } func (t *Tracker) bindSessionsLocked() { - for _, sess := range t.sessions { - if sess.tabID != 0 { - continue - } - if tabID := t.tabsByTarget[sess.target.TargetID]; tabID != 0 { - sess.tabID = tabID - continue - } - if sess.target.Type == "iframe" { - if parent := t.sessions[sess.parentID]; parent != nil && parent.tabID != 0 { - sess.tabID = parent.tabID - continue + for changed := true; changed; { + changed = false + for _, sess := range t.sessions { + // Explicit flat attachments have no parent session on the message. + // Recover it from target identity even when frame tracking is disabled. + if sess.target.Type != "page" && sess.parentID == "" { + for id, parent := range t.sessions { + if id != sess.id && parent.target.TargetID == sess.target.ParentFrameID { + sess.parentID = id + changed = true + break + } + } } - if parentFrame := t.frames[sess.target.ParentFrameID]; parentFrame != nil { - sess.tabID = parentFrame.tabID + if sess.tabID != 0 { continue } - if ownFrame := t.frames[sess.target.TargetID]; ownFrame != nil { + if tabID := t.tabsByTarget[sess.target.TargetID]; tabID != 0 { + sess.tabID = tabID + } else if parent := t.sessions[sess.parentID]; parent != nil && parent.tabID != 0 { + sess.tabID = parent.tabID + } else if parentFrame := t.frames[sess.target.ParentFrameID]; parentFrame != nil { + sess.tabID = parentFrame.tabID + } else if ownFrame := t.frames[sess.target.TargetID]; ownFrame != nil { sess.tabID = ownFrame.tabID } + if sess.tabID != 0 { + changed = true + } } } } @@ -290,8 +324,8 @@ func (t *Tracker) removeSessionLocked(sessionID string) []string { switch sess.target.Type { case "page": t.trackingTarget[sess.target.TargetID] = false - case "iframe": - delete(t.trackingFrameTarget, sess.target.TargetID) + default: + delete(t.trackingNonPageTarget, sess.target.TargetID) } delete(t.sessions, id) removed = append(removed, id) diff --git a/server/lib/browsersurface/sessions_test.go b/server/lib/browsersurface/sessions_test.go new file mode 100644 index 000000000..c4f2ea41f --- /dev/null +++ b/server/lib/browsersurface/sessions_test.go @@ -0,0 +1,150 @@ +package browsersurface + +import ( + "context" + "maps" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestWorkerDiscoveryIsOptIn(t *testing.T) { + for _, enabled := range []bool{false, true} { + for _, existing := range []bool{false, true} { + t.Run(testMode(enabled, existing), func(t *testing.T) { + protocol := newFakeProtocol() + targets := []targetInfo{ + {TargetID: "worker", Type: "worker", URL: "https://example.com/worker.js"}, + {TargetID: "shared_worker", Type: "shared_worker", URL: "https://example.com/shared.js"}, + {TargetID: "service_worker", Type: "service_worker", URL: "https://example.com/service.js"}, + } + protocol.targetInfos = make([]targetInfo, 0) + if existing { + protocol.targetInfos = targets + } + var options []Option + if enabled { + options = append(options, WithAdditionalTargets("worker", "shared_worker", "service_worker")) + } + tracker := New(protocol, options...) + events, unsubscribe := tracker.Subscribe() + defer unsubscribe() + defer close(protocol.events) + require.NoError(t, tracker.Start(context.Background())) + protocol.emitTarget("Target.attachedToTarget", map[string]any{ + "sessionId": "worker-session", "targetInfo": targets[0], + }) + for _, target := range targets { + // Repeated discovery must not create another session. + protocol.emitTarget("Target.targetCreated", map[string]any{"targetInfo": target}) + } + attached := make(map[string]bool) + created := 0 + timeout := time.After(time.Second) + for created != len(targets) || (enabled && len(attached) != len(targets)) { + select { + case event := <-events: + if event.Kind == EventSessionAttached { + require.True(t, enabled) + require.False(t, attached[event.Target.Type]) + attached[event.Target.Type] = true + require.True(t, tracker.SessionExists(event.SessionID)) + } + if event.Kind == EventProtocol && event.Message.Method == "Target.targetCreated" { + created++ + } + case <-timeout: + t.Fatal("worker discovery events did not arrive") + } + } + if enabled { + require.Eventually(t, func() bool { + protocol.mu.Lock() + defer protocol.mu.Unlock() + return protocol.autoAttachCalls["worker-session"] == 1 + }, time.Second, time.Millisecond, "nested dedicated-worker discovery must be enabled") + } + protocol.mu.Lock() + calls := maps.Clone(protocol.attachCalls) + types := slices.Clone(protocol.discoveredTypes) + pageCalls := maps.Clone(protocol.pageEnableCalls) + protocol.mu.Unlock() + for _, target := range targets { + if enabled { + if target.Type == "worker" { + require.Zero(t, calls[target.TargetID], "dedicated workers attach only through their parent") + } else { + require.Equal(t, 1, calls[target.TargetID]) + } + require.Contains(t, types, target.Type) + } else { + require.Zero(t, calls[target.TargetID]) + require.NotContains(t, types, target.Type) + } + } + require.Empty(t, pageCalls, "worker sessions must not initialize Page domains") + }) + } + } +} + +func testMode(enabled, existing bool) string { + mode := "default" + if enabled { + mode = "workers" + } + if existing { + return mode + "/existing" + } + return mode + "/new" +} + +func TestSessionOnlyTrackingDoesNotWaitForLocations(t *testing.T) { + protocol := newFakeProtocol() + protocol.pageEnableFailures["session-a"] = 100 + tracker := New(protocol, WithoutLocations(), WithAdditionalTargets("worker")) + events, unsubscribe := tracker.Subscribe() + defer unsubscribe() + defer close(protocol.events) + require.NoError(t, tracker.Start(context.Background())) + + attached := 0 + for attached != 2 { + select { + case event := <-events: + if event.Kind == EventSessionAttached { + attached++ + } + case <-time.After(time.Second): + t.Fatal("page attachment waited for location initialization") + } + } + protocol.mu.Lock() + pageCalls := maps.Clone(protocol.pageEnableCalls) + protocol.mu.Unlock() + require.Empty(t, pageCalls) + require.Empty(t, tracker.Snapshot().Tabs) + + // Children can arrive before their parents and have no Page frame tree. + tracker.addSession("nested", "", targetInfo{TargetID: "nested", Type: "iframe", ParentFrameID: "oopif"}) + tracker.addSession("child", "", targetInfo{TargetID: "oopif", Type: "iframe", ParentFrameID: "page-a"}) + tracker.removeTarget("page-a") + require.False(t, tracker.SessionExists("session-a")) + require.False(t, tracker.SessionExists("child")) + require.False(t, tracker.SessionExists("nested")) + require.True(t, tracker.SessionExists("session-b")) + tracker.addSession("late-worker", "session-a", targetInfo{TargetID: "worker", Type: "worker"}) + require.False(t, tracker.SessionExists("late-worker")) +} + +func TestLateAttachResponseDoesNotRetainClosedTarget(t *testing.T) { + tracker := New(newFakeProtocol(), WithoutLocations()) + defer tracker.stop() + target := targetInfo{TargetID: "page-a", Type: "page", URL: "https://example.com/"} + tracker.registerPage(target) + tracker.removeTarget(target.TargetID) + require.NoError(t, tracker.attachTarget(target)) + require.False(t, tracker.SessionExists("session-a")) +} diff --git a/server/lib/browsersurface/targets.go b/server/lib/browsersurface/targets.go index 5930ec538..1c44e87c9 100644 --- a/server/lib/browsersurface/targets.go +++ b/server/lib/browsersurface/targets.go @@ -41,7 +41,7 @@ func (t *Tracker) registerPage(target targetInfo) (int, bool) { return tabID, true } -func (t *Tracker) attachPage(tabID int, target targetInfo) { +func (t *Tracker) assignPageWindow(tabID int, target targetInfo) { windowCtx, cancel := context.WithTimeout(t.ctx, 2*time.Second) raw, err := t.protocol.Send(windowCtx, "Browser.getWindowForTarget", map[string]any{"targetId": target.TargetID}, "") cancel() @@ -58,6 +58,12 @@ func (t *Tracker) attachPage(tabID int, target targetInfo) { if !windowAssigned { t.assignFallbackWindow(target.TargetID, tabID) } +} + +func (t *Tracker) attachPage(tabID int, target targetInfo) { + if t.trackLocations { + t.assignPageWindow(tabID, target) + } if err := t.attachTarget(target); err == nil { return } else if !t.protocol.IsClosed() { @@ -91,26 +97,39 @@ func (t *Tracker) attachTarget(target targetInfo) error { return fmt.Errorf("attach browser target: missing session ID") } t.addSession(result.SessionID, "", target) + // The target can close while the attach command is in flight. Check after + // adding the session so a concurrent removal cannot leave it untracked. + t.stateMu.RLock() + tracking := t.trackingTarget[target.TargetID] || t.trackingNonPageTarget[target.TargetID] + t.stateMu.RUnlock() + if !tracking { + t.removeSession(result.SessionID) + } return nil } -func (t *Tracker) trackFrameTarget(target targetInfo) { +func (t *Tracker) trackNonPageTarget(target targetInfo) { + // Dedicated workers are attached through their parent's Target domain; + // browser-wide enumeration/discovery does not reliably expose them. + if target.Type == "worker" { + return + } t.stateMu.Lock() - if t.trackingFrameTarget[target.TargetID] { + if t.trackingNonPageTarget[target.TargetID] { t.stateMu.Unlock() return } - t.trackingFrameTarget[target.TargetID] = true + t.trackingNonPageTarget[target.TargetID] = true t.stateMu.Unlock() go func() { if err := t.attachTarget(target); err == nil { return } else if !t.protocol.IsClosed() { - t.logger.Warn("failed to attach browser frame", "target_id", target.TargetID, "err", err) + t.logger.Warn("failed to attach browser target", "target_id", target.TargetID, "type", target.Type, "err", err) } t.stateMu.Lock() - delete(t.trackingFrameTarget, target.TargetID) + delete(t.trackingNonPageTarget, target.TargetID) t.stateMu.Unlock() t.signalChanged() }() @@ -122,9 +141,9 @@ func (t *Tracker) RefreshTargets(ctx context.Context) error { for targetID := range t.tabsByTarget { known = append(known, targetID) } - knownFrames := make([]string, 0, len(t.trackingFrameTarget)) - for targetID := range t.trackingFrameTarget { - knownFrames = append(knownFrames, targetID) + knownNonPages := make([]string, 0, len(t.trackingNonPageTarget)) + for targetID := range t.trackingNonPageTarget { + knownNonPages = append(knownNonPages, targetID) } t.stateMu.RUnlock() @@ -139,15 +158,17 @@ func (t *Tracker) RefreshTargets(ctx context.Context) error { return err } active := make(map[string]bool) - activeFrames := make(map[string]bool) + activeNonPages := make(map[string]bool) for _, target := range result.TargetInfos { switch target.Type { case "page": active[target.TargetID] = true t.trackPage(target, true) - case "iframe": - activeFrames[target.TargetID] = true - t.trackFrameTarget(target) + default: + if t.tracksTarget(target.Type) { + activeNonPages[target.TargetID] = true + t.trackNonPageTarget(target) + } } } for _, targetID := range known { @@ -156,8 +177,8 @@ func (t *Tracker) RefreshTargets(ctx context.Context) error { } t.removeTarget(targetID) } - for _, targetID := range knownFrames { - if !activeFrames[targetID] { + for _, targetID := range knownNonPages { + if !activeNonPages[targetID] { t.removeTarget(targetID) } } @@ -208,15 +229,15 @@ func (t *Tracker) updateTarget(target targetInfo) { } t.stateMu.Unlock() t.signalChanged() - if target.Type == "iframe" { - t.trackFrameTarget(target) + if target.Type != "page" && t.tracksTarget(target.Type) { + t.trackNonPageTarget(target) return } if target.Type == "page" && !knownTab { t.trackPage(target, true) return } - if target.Type == "page" { + if target.Type == "page" && t.trackLocations { go func() { ctx, cancel := context.WithTimeout(t.ctx, 2*time.Second) defer cancel() @@ -243,7 +264,7 @@ func (t *Tracker) updateTabTargetLocked(target targetInfo) { func (t *Tracker) removeTarget(targetID string) { t.stateMu.Lock() - delete(t.trackingFrameTarget, targetID) + delete(t.trackingNonPageTarget, targetID) var removedSessions []string if tabID := t.tabsByTarget[targetID]; tabID != 0 { removedSessions = t.removeTabLocked(tabID) diff --git a/server/lib/browsersurface/tracker.go b/server/lib/browsersurface/tracker.go index a9f1b8927..041416138 100644 --- a/server/lib/browsersurface/tracker.go +++ b/server/lib/browsersurface/tracker.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "slices" "sort" "strings" "sync" @@ -16,6 +17,21 @@ const ( sessionInitRetryDelay = 50 * time.Millisecond ) +type Option func(*Tracker) + +// WithAdditionalTargets includes target types beyond pages and iframes. They +// emit session and protocol events but do not get page/frame initialization. +func WithAdditionalTargets(targetTypes ...string) Option { + return func(t *Tracker) { t.additionalTargets = slices.Clone(targetTypes) } +} + +// WithoutLocations tracks sessions without window lookup or Page-domain frame +// initialization. Consumers use SessionAttached, SessionRemoved, and Protocol +// events and own their domain setup; location snapshots are not populated. +func WithoutLocations() Option { + return func(t *Tracker) { t.trackLocations = false } +} + type subscriber struct { events chan Event done chan struct{} @@ -25,27 +41,29 @@ type subscriber struct { // tabs, and embedded frames. IDs are monotonically increasing for the lifetime // of the Chromium process and are never reused. type Tracker struct { - protocol Protocol - ctx context.Context - cancel context.CancelFunc - logger *slog.Logger + protocol Protocol + ctx context.Context + cancel context.CancelFunc + logger *slog.Logger + additionalTargets []string + trackLocations bool startMu sync.Mutex started bool startErr error - stateMu sync.RWMutex - nextWindowID int - nextTabID int - nextFrameID int - windows map[int64]window - tabs map[int]*tab - tabsByTarget map[string]int - frames map[string]*frame - sessions map[string]*session - trackingTarget map[string]bool - trackingFrameTarget map[string]bool - stateChanged chan struct{} + stateMu sync.RWMutex + nextWindowID int + nextTabID int + nextFrameID int + windows map[int64]window + tabs map[int]*tab + tabsByTarget map[string]int + frames map[string]*frame + sessions map[string]*session + trackingTarget map[string]bool + trackingNonPageTarget map[string]bool + stateChanged chan struct{} subMu sync.Mutex nextSubID int @@ -54,23 +72,37 @@ type Tracker struct { closeOnce sync.Once } -func New(protocol Protocol) *Tracker { +func New(protocol Protocol, options ...Option) *Tracker { ctx, cancel := context.WithCancel(context.Background()) - return &Tracker{ - protocol: protocol, - ctx: ctx, - cancel: cancel, - logger: slog.Default(), - windows: make(map[int64]window), - tabs: make(map[int]*tab), - tabsByTarget: make(map[string]int), - frames: make(map[string]*frame), - sessions: make(map[string]*session), - trackingTarget: make(map[string]bool), - trackingFrameTarget: make(map[string]bool), - stateChanged: make(chan struct{}, 1), - subscribers: make(map[int]*subscriber), - closed: make(chan struct{}), + t := &Tracker{ + protocol: protocol, + trackLocations: true, + ctx: ctx, + cancel: cancel, + logger: slog.Default(), + windows: make(map[int64]window), + tabs: make(map[int]*tab), + tabsByTarget: make(map[string]int), + frames: make(map[string]*frame), + sessions: make(map[string]*session), + trackingTarget: make(map[string]bool), + trackingNonPageTarget: make(map[string]bool), + stateChanged: make(chan struct{}, 1), + subscribers: make(map[int]*subscriber), + closed: make(chan struct{}), + } + for _, option := range options { + option(t) + } + return t +} + +func (t *Tracker) tracksTarget(targetType string) bool { + switch targetType { + case "page", "iframe": + return true + default: + return slices.Contains(t.additionalTargets, targetType) } } @@ -87,12 +119,13 @@ func (t *Tracker) Start(ctx context.Context) error { } go t.eventLoop() + filter := []map[string]any{{"type": "page"}, {"type": "iframe"}} + for _, targetType := range t.additionalTargets { + filter = append(filter, map[string]any{"type": targetType}) + } if _, err := t.protocol.Send(ctx, "Target.setDiscoverTargets", map[string]any{ "discover": true, - "filter": []map[string]any{ - {"type": "page"}, - {"type": "iframe"}, - }, + "filter": filter, }, ""); err != nil { t.startErr = fmt.Errorf("start browser surface discovery: %w", err) return t.startErr @@ -115,8 +148,8 @@ func (t *Tracker) Start(ctx context.Context) error { } } for _, target := range result.TargetInfos { - if target.Type == "iframe" { - t.trackFrameTarget(target) + if target.Type != "page" && t.tracksTarget(target.Type) { + t.trackNonPageTarget(target) } } return nil @@ -168,6 +201,9 @@ func (t *Tracker) HasTabs() bool { } func (t *Tracker) RefreshWindows(ctx context.Context) { + if !t.trackLocations { + return + } t.stateMu.RLock() tabs := make([]tab, 0, len(t.tabs)) for _, tracked := range t.tabs { @@ -199,6 +235,9 @@ func (t *Tracker) Snapshot() Snapshot { Tabs: make([]TabInfo, 0, len(t.tabs)), Frames: make([]FrameInfo, 0, len(t.frames)), } + if !t.trackLocations { + return snapshot + } for _, tracked := range t.windows { snapshot.Windows = append(snapshot.Windows, WindowInfo{ID: tracked.id}) } diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go index 5114ea91b..3ac6dadbe 100644 --- a/server/lib/browsersurface/tracker_test.go +++ b/server/lib/browsersurface/tracker_test.go @@ -17,11 +17,14 @@ type fakeProtocol struct { windowIDs map[string]int attachFailures int attachCalls map[string]int + autoAttachCalls map[string]int detachCalls map[string]int pageEnableFailures map[string]int pageEnableCalls map[string]int frameTreeFailures map[string]int beforeTargetsResult func() + targetInfos []targetInfo + discoveredTypes []string events chan cdpclient.Message closed chan struct{} } @@ -30,6 +33,7 @@ func newFakeProtocol() *fakeProtocol { return &fakeProtocol{ windowIDs: map[string]int{"page-a": 10, "page-b": 20}, attachCalls: make(map[string]int), + autoAttachCalls: make(map[string]int), detachCalls: make(map[string]int), pageEnableFailures: make(map[string]int), pageEnableCalls: make(map[string]int), @@ -46,15 +50,29 @@ func (f *fakeProtocol) Send(_ context.Context, method string, params any, sessio } switch method { case "Target.setDiscoverTargets": + f.mu.Lock() + for _, entry := range params.(map[string]any)["filter"].([]map[string]any) { + f.discoveredTypes = append(f.discoveredTypes, entry["type"].(string)) + } + f.mu.Unlock() + return marshal(map[string]any{}), nil + case "Target.setAutoAttach": + f.mu.Lock() + f.autoAttachCalls[sessionID]++ + f.mu.Unlock() return marshal(map[string]any{}), nil case "Target.getTargets": f.mu.Lock() beforeResult := f.beforeTargetsResult + targetInfos := f.targetInfos f.beforeTargetsResult = nil f.mu.Unlock() if beforeResult != nil { beforeResult() } + if targetInfos != nil { + return marshal(map[string]any{"targetInfos": targetInfos}), nil + } return marshal(map[string]any{"targetInfos": []map[string]any{ {"targetId": "page-a", "type": "page", "title": "Store", "url": "https://store.example/"}, {"targetId": "page-b", "type": "page", "title": "Travel", "url": "https://travel.example/"}, @@ -77,6 +95,7 @@ func (f *fakeProtocol) Send(_ context.Context, method string, params any, sessio f.mu.Unlock() sessionID := map[string]string{ "page-a": "session-a", "page-b": "session-b", "late-page": "late-session", "oopif": "oopif-session", + "worker": "worker-session", "shared_worker": "shared-worker-session", "service_worker": "service-worker-session", }[targetID] return marshal(map[string]any{"sessionId": sessionID}), nil case "Target.detachFromTarget": diff --git a/server/lib/browsersurface/types.go b/server/lib/browsersurface/types.go index 658324aab..89e533240 100644 --- a/server/lib/browsersurface/types.go +++ b/server/lib/browsersurface/types.go @@ -23,13 +23,25 @@ const ( EventDocumentChanged EventFrameInvalidated EventFrameRemoved + // EventSessionAttached precedes page/frame initialization. Consumers can + // enable domains without waiting for a tab or frame location to resolve. + EventSessionAttached ) +type SessionTarget struct { + ID string + Type string + URL string + Title string + OpenerID string +} + type Event struct { Kind EventKind SessionID string FrameID string Message cdpclient.Message + Target SessionTarget // populated on EventSessionAttached } type WindowInfo struct { @@ -75,6 +87,7 @@ type targetInfo struct { Title string `json:"title"` URL string `json:"url"` ParentFrameID string `json:"parentFrameId,omitempty"` + OpenerID string `json:"openerId,omitempty"` } type frameInfo struct { diff --git a/server/lib/cdpmonitor/README.md b/server/lib/cdpmonitor/README.md index e9d95e828..c8bcee28c 100644 --- a/server/lib/cdpmonitor/README.md +++ b/server/lib/cdpmonitor/README.md @@ -1,6 +1,6 @@ # CDP Monitor -The monitor is the browser-facing layer of the kernel browser logging pipeline. It connects to Chrome's DevTools endpoint, tracks all page sessions via CDP's `Target.setAutoAttach`, and converts raw CDP notifications into typed `events.Event` values for downstream consumers. +The monitor is the browser-facing layer of the kernel browser logging pipeline. It owns a connection to Chrome's DevTools endpoint, uses `browsersurface` to track page, iframe, and worker sessions, and converts raw CDP notifications into typed `events.Event` values for downstream consumers. ## Overview @@ -24,11 +24,12 @@ browser contexts, with frames created before and after the monitor starts. It verifies browser results and server-received bodies before asserting telemetry request/response bodies and failure correlation. -These tests currently fail for cross-origin and nested cross-origin frames because -the collector does not attach their OOPIF targets. Top-level and same-origin controls -pass. They assert the desired behavior, not the current omission, and do not prescribe -an attachment strategy. Without the environment variable they skip, matching the -other real-Chromium tests in this package. +All 32 cases assert successful capture, without prescribing an attachment strategy. +`TestNetworkCaptureFromWorkers` covers dedicated, shared, and service workers; +`TestTelemetryConnectionOwnershipAndReconnect` checks independent client ownership +and fresh sessions after reconnect. Without the environment variable these tests +skip, matching the other real-Chromium tests in this package. They exercise settled +targets, not requests issued before their capture domains finish initializing. ## Event taxonomy @@ -44,7 +45,9 @@ other real-Chromium tests in this package. | Concern | Where | | --- | --- | -| WebSocket lifecycle (connect, read, reconnect) | `monitor.go` | +| Connection ownership and reconnect | `monitor.go` | +| CDP transport and command routing | `../cdpclient` | +| Target discovery and attachment lifecycle | `../browsersurface` | | CDP domain setup per session | `domains.go` | | Event translation (CDP params to `events.Event`) | `handlers.go` | | Synthetic event state machines | `computed.go` | @@ -55,53 +58,48 @@ other real-Chromium tests in this package. ## Internals -### Reconnect model - -`subscribeToUpstream` listens to `UpstreamProvider.Subscribe()` for new DevTools URLs. On each URL change (indicating Chrome restarted), `handleUpstreamRestart` tears down the existing connection, dials the new URL with capped-exponential backoff (250 ms → 500 ms → 1 s → 2 s, up to 10 attempts), then restarts `readLoop` and re-initializes all CDP sessions. `restartMu` serializes concurrent restart signals so rapid Chrome restarts do not produce overlapping reconnects. - -### Goroutines - -| Goroutine | Lifetime | Tracked by | -| --- | --- | --- | -| `readLoop` | one per WebSocket connection | `done` channel | -| `subscribeToUpstream` | same as `lifecycleCtx` | `asyncWg` | -| `sweepPendingRequests` | same as `lifecycleCtx` | `asyncWg` | -| `initSession` | short-lived, one per connect or reconnect | `asyncWg` | -| `attachExistingTargets` wrapper | short-lived, one per existing target on reconnect | `asyncWg` | -| `enableDomains` + `injectScript` | short-lived, one per target attach | `asyncWg` | -| `fetchResponseBody` | one per completed network request | `asyncWg` | -| `captureScreenshot` | one per screenshot trigger | `asyncWg` | - -`Stop()` cancels `lifecycleCtx`, waits for `readLoop` via `done`, then waits for all other goroutines via `asyncWg` before closing the connection. - -### Lock ordering - -Locks must be acquired left to right. Never hold a lock on the left while acquiring one further right. - -``` -restartMu -> lifeMu -> pendReqMu -> computed.mu -> pendMu -restartMu -> lifeMu -> sessionsMu -``` - -`computed.mu` and `sessionsMu` are never held simultaneously; `cs.stop()` and `cs.resetOnNavigation()` are called only after the relevant `sessionsMu` critical section is complete. - -`bindingRateMu` is independent of this ordering and is always acquired alone. - -| Lock | Protects | -| --- | --- | -| `restartMu` | Serializes `handleUpstreamRestart` to prevent overlapping reconnects from rapid Chrome restarts | -| `lifeMu` | `conn`, `lifecycleCtx`, `cancel`, `done`, `readReady`: all fields that change during Start / Stop / reconnect | -| `pendReqMu` | `pendingRequests` (requestId -> `networkReqState`): in-flight network requests accumulating request/response metadata until `loadingFinished` | -| `computed.mu` | All `computedState` fields: counters and timers for the `network_idle`, `page_layout_settled`, and `page_navigation_settled` state machines | -| `pendMu` | `pending` (id -> reply channel): in-flight CDP commands waiting for a response from Chrome | -| `sessionsMu` | `sessions` (sessionID -> `targetInfo`): the set of currently attached CDP targets (tabs, iframes, workers) | -| `bindingRateMu` | `bindingLastSeen` (sessionID:eventType -> time): rate-limit state for `__kernelEvent` binding calls | - -Fields that need no mutex use `sync/atomic`: `nextID`, `mainSessionID`, `running`, `lastScreenshotAt`, `screenshotInFlight`. - -### WebSocket concurrency - -`coder/websocket` guarantees one concurrent `Read` and one concurrent `Write` are safe on the same connection. `readLoop` is the sole reader. All writes go through `send`, which calls `conn.Write` directly; `conn.Write` is internally serialized by the library, so no external write mutex is needed. +### Connection ownership and discovery + +Each monitor owns a `cdpclient.Client` and a `browsersurface.Tracker`. No connection, +CDP session, or tracker state is shared with WebMCP. Telemetry adds worker and +background-page targets with `WithAdditionalTargets` and uses `WithoutLocations`: it receives attachment events without waiting for window +lookup or frame-tree initialization, and enables its own capture domains. + +The tracker explicitly discovers and attaches pages, OOPIFs, shared workers, and +service workers (and extension background pages). Dedicated workers require parent-session `Target.setAutoAttach`; +that subscription is also installed on worker sessions to discover nested workers. +Only `worker` targets match this auto-attach filter, avoiding duplicate attachment +of explicitly discovered OOPIFs. WebMCP's default tracker still tracks page/frame +locations and does not subscribe to workers. + +### Reconnect and shutdown + +`subscribeToUpstream` listens for new DevTools URLs. `handleUpstreamRestart` cancels +the current connection context, closes its protocol, stops consuming its events, +and drains capture work before clearing state and constructing a new client/tracker. +Dial retries use capped-exponential backoff (250 ms, 500 ms, 1 s, then 2 s; at most +10 attempts). CDP sessions and pending requests never carry into the next connection. + +`asyncWg` tracks the upstream listener and request sweeper. `captureWg` tracks +tracker startup, domain initialization, body fetches, and screenshots. `Stop` cancels +the lifecycle, waits for the lifecycle workers, and drains the current connection. +Closing the protocol unblocks pending commands, including callers without the +monitor's cancellation context. + +### Synchronization + +`restartMu` serializes connection replacement. `lifeMu` protects the connection +pointer and lifecycle context/cancel function; it is released before waiting on +connection or capture work. `sessionsMu` protects target metadata and computed-state +lookup. `pendReqMu` protects requests keyed by **(CDP session ID, request ID)**; +request IDs from different targets must not collide. Detaching a session removes +only that session's requests. + +`computed.mu` and `sessionsMu` are never held simultaneously. Navigation updates +can hold `pendReqMu` while acquiring `computed.mu`, not the reverse. Binding/proxy +rate-limit locks are acquired independently. Screenshot state, the main page +session, and running state use atomics. CDP reads and command-response routing are +owned by `cdpclient`; the monitor consumes the tracker's ordered event subscription. ## Event data model diff --git a/server/lib/cdpmonitor/cdp_test.go b/server/lib/cdpmonitor/cdp_test.go index f5a43c17f..353b2b4bd 100644 --- a/server/lib/cdpmonitor/cdp_test.go +++ b/server/lib/cdpmonitor/cdp_test.go @@ -383,7 +383,7 @@ func simulateRequest(m *Monitor, id string) { // simulateFinished stores minimal state and sends Network.loadingFinished. func simulateFinished(m *Monitor, id string) { m.pendReqMu.Lock() - m.pendingRequests[id] = networkReqState{sessionID: "s1", method: "GET", url: "https://example.com/" + id} + m.pendingRequests[networkRequestKey{"s1", id}] = networkReqState{sessionID: "s1", method: "GET", url: "https://example.com/" + id} m.pendReqMu.Unlock() m.handleLoadingFinished(context.Background(), cdpNetworkLoadingFinishedParams{RequestID: id}, "s1") } diff --git a/server/lib/cdpmonitor/chrome_e2e_test.go b/server/lib/cdpmonitor/chrome_e2e_test.go index 73a1c27fe..c4bf243ba 100644 --- a/server/lib/cdpmonitor/chrome_e2e_test.go +++ b/server/lib/cdpmonitor/chrome_e2e_test.go @@ -11,6 +11,7 @@ import ( "strings" "sync" "sync/atomic" + "syscall" "testing" "time" @@ -120,10 +121,15 @@ func launchChromium(t *testing.T, ctx context.Context, chrome string, extraArgs args = append(args, extraArgs...) args = append(args, "about:blank") cmd := exec.CommandContext(ctx, chrome, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} stderr, err := cmd.StderrPipe() require.NoError(t, err) require.NoError(t, cmd.Start()) - t.Cleanup(func() { _ = cmd.Process.Kill(); _, _ = cmd.Process.Wait() }) + t.Cleanup(func() { + // Stop renderer/worker children before TempDir cleanup, not just Chrome's parent. + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + _, _ = cmd.Process.Wait() + }) // Chromium prints "DevTools listening on ws://..." to stderr once ready. buf := make([]byte, 4096) diff --git a/server/lib/cdpmonitor/handlers.go b/server/lib/cdpmonitor/handlers.go index acfffce2b..67bc682b6 100644 --- a/server/lib/cdpmonitor/handlers.go +++ b/server/lib/cdpmonitor/handlers.go @@ -101,6 +101,9 @@ func (m *Monitor) decodeParams(method string, params json.RawMessage, dst any) b func (m *Monitor) dispatchEvent(msg cdpMessage) { m.lifeMu.Lock() ctx := m.lifecycleCtx + if m.conn != nil { + ctx = m.conn.ctx + } m.lifeMu.Unlock() switch msg.Method { @@ -437,15 +440,16 @@ func (m *Monitor) handleNetworkRequest(p cdpNetworkRequestWillBeSentParams, sess // events, but only a single loadingFinished fires per chain. Only increment // netPending for genuinely new requests to avoid permanently inflating the // counter and blocking network_idle. + key := networkRequestKey{sessionID, p.RequestID} m.pendReqMu.Lock() - existing, isRedirect := m.pendingRequests[p.RequestID] + existing, isRedirect := m.pendingRequests[key] addedAt := existing.addedAt if !isRedirect { addedAt = time.Now() } else { navSeq = existing.navSeq } - m.pendingRequests[p.RequestID] = networkReqState{ + m.pendingRequests[key] = networkReqState{ sessionID: sessionID, method: p.Request.Method, url: p.Request.URL, @@ -492,17 +496,18 @@ func (m *Monitor) handleNetworkRequest(p cdpNetworkRequestWillBeSentParams, sess } func (m *Monitor) handleResponseReceived(p cdpNetworkResponseReceivedParams, sessionID string) { + key := networkRequestKey{sessionID, p.RequestID} m.pendReqMu.Lock() var ( state networkReqState ok bool ) - if st, present := m.pendingRequests[p.RequestID]; present { + if st, present := m.pendingRequests[key]; present { st.status = p.Response.Status st.statusText = p.Response.StatusText st.resHeaders = p.Response.Headers st.mimeType = p.Response.MimeType - m.pendingRequests[p.RequestID] = st + m.pendingRequests[key] = st state, ok = st, true } m.pendReqMu.Unlock() @@ -545,10 +550,11 @@ func (m *Monitor) handleResponseReceived(p cdpNetworkResponseReceivedParams, ses } func (m *Monitor) handleLoadingFinished(ctx context.Context, p cdpNetworkLoadingFinishedParams, sessionID string) { + key := networkRequestKey{sessionID, p.RequestID} m.pendReqMu.Lock() - state, ok := m.pendingRequests[p.RequestID] + state, ok := m.pendingRequests[key] if ok { - delete(m.pendingRequests, p.RequestID) + delete(m.pendingRequests, key) } m.pendReqMu.Unlock() if !ok { @@ -561,7 +567,7 @@ func (m *Monitor) handleLoadingFinished(ctx context.Context, p cdpNetworkLoading cs.onLoadingFinished() } // Fetch response body async to avoid blocking readLoop; binary types are skipped. - m.asyncWg.Go(func() { + m.captureWg.Go(func() { body := m.fetchResponseBody(ctx, p.RequestID, sessionID, state) var hdrs oapi.BrowserHttpHeaders _ = json.Unmarshal(state.resHeaders, &hdrs) @@ -625,10 +631,11 @@ func (m *Monitor) fetchResponseBody(ctx context.Context, requestID, sessionID st } func (m *Monitor) handleLoadingFailed(p cdpNetworkLoadingFailedParams, sessionID string) { + key := networkRequestKey{sessionID, p.RequestID} m.pendReqMu.Lock() - state, ok := m.pendingRequests[p.RequestID] + state, ok := m.pendingRequests[key] if ok { - delete(m.pendingRequests, p.RequestID) + delete(m.pendingRequests, key) } m.pendReqMu.Unlock() @@ -772,9 +779,9 @@ func (m *Monitor) handleFrameNavigated(p cdpPageFrameNavigatedParams, sessionID }) m.publishEvent(EventNavigation, events.Page, oapi.BrowserEventSource{Kind: oapi.Cdp}, "Page.frameNavigated", data, sessionID) - // Only reset state for top-level navigations; subframe (iframe) navigations - // should not disrupt main-page tracking. - if p.Frame.ParentID == "" { + // An OOPIF's local root can omit parentId. Only page targets can own + // top-level navigation state and screenshot triggers. + if p.Frame.ParentID == "" && info.targetType == targetTypePage { m.mainSessionID.Store(sessionID) navCtx := navContext{ @@ -852,6 +859,10 @@ func (m *Monitor) handleLoadEventFired(ctx context.Context, p cdpPageLoadEventFi // attached to is in p.SessionID. func (m *Monitor) handleAttachedToTarget(ctx context.Context, p cdpTargetAttachedToTargetParams) { m.sessionsMu.Lock() + if _, exists := m.sessions[p.SessionID]; exists { + m.sessionsMu.Unlock() + return + } m.sessions[p.SessionID] = targetInfo{ targetID: p.TargetInfo.TargetID, url: p.TargetInfo.URL, @@ -874,8 +885,8 @@ func (m *Monitor) handleAttachedToTarget(ctx context.Context, p cdpTargetAttache } targetType := p.TargetInfo.Type - // Async to avoid blocking the readLoop. - m.asyncWg.Go(func() { + // Domain setup must not block delivery of protocol events. + m.captureWg.Go(func() { m.enableDomains(ctx, p.SessionID, targetType) if isPageLikeTarget(targetType) { _ = m.injectScript(ctx, p.SessionID) @@ -895,4 +906,11 @@ func (m *Monitor) handleDetachedFromTarget(p cdpTargetDetachedFromTargetParams) if cs != nil { cs.stop() } + m.pendReqMu.Lock() + for key, request := range m.pendingRequests { + if request.sessionID == p.SessionID { + delete(m.pendingRequests, key) + } + } + m.pendReqMu.Unlock() } diff --git a/server/lib/cdpmonitor/lifecycle_chrome_e2e_test.go b/server/lib/cdpmonitor/lifecycle_chrome_e2e_test.go new file mode 100644 index 000000000..f38419514 --- /dev/null +++ b/server/lib/cdpmonitor/lifecycle_chrome_e2e_test.go @@ -0,0 +1,113 @@ +package cdpmonitor + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/browsersurface" + "github.com/kernel/kernel-images/server/lib/cdpclient" + "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +func TestTelemetryConnectionOwnershipAndReconnect(t *testing.T) { + if os.Getenv("KERNEL_CDPMONITOR_CHROME_E2E") == "" { + t.Skip("set KERNEL_CDPMONITOR_CHROME_E2E=1 to run the connection ownership test") + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.Header().Set("Content-Type", "text/plain") + _, _ = io.Copy(w, r.Body) + return + } + fmt.Fprint(w, "connection ownership") + })) + defer stub.Close() + browserWS := launchChromium(t, ctx, findChromium(t)) + driver := dialCDP(t, ctx, browserWS) + defer driver.close() + targetID := driver.call(t, ctx, "", "Target.createTarget", map[string]any{"url": stub.URL}).targetID(t) + driverSession := driver.call(t, ctx, "", "Target.attachToTarget", map[string]any{"targetId": targetID, "flatten": true}).sessionID(t) + + // This follows WebMCP's ownership pattern: its own protocol and default tracker. + otherProtocol, err := cdpclient.DialWithEvents(ctx, browserWS) + require.NoError(t, err) + defer otherProtocol.Close() + otherSurface := browsersurface.New(otherProtocol) + otherEvents, unsubscribe := otherSurface.Subscribe() + defer unsubscribe() + require.NoError(t, otherSurface.Start(ctx)) + var otherSession string + for otherSession == "" { + select { + case event := <-otherEvents: + if event.Kind == browsersurface.EventSessionAttached && event.Target.ID == targetID { + otherSession = event.SessionID + } + case <-ctx.Done(): + t.Fatal("independent tracker did not attach the page") + } + } + + upstream := newTestUpstream(browserWS) + ec := newEventCollector() + m := New(upstream, ec.publishFn(), 99, discardLogger, nil) + require.NoError(t, m.Start(ctx)) + defer m.Stop() + require.Eventually(t, func() bool { + return driver.evalBool(ctx, driverSession, `document.readyState === 'complete' && window.__kernelEventInjected === true`) + }, 5*time.Second, 50*time.Millisecond) + m.Stop() + require.True(t, otherSurface.SessionExists(otherSession)) + result, err := otherProtocol.Send(ctx, "Runtime.evaluate", map[string]any{"expression": "6 * 7", "returnByValue": true}, otherSession) + require.NoError(t, err) + var evaluation struct { + Result struct { + Value int `json:"value"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(result, &evaluation)) + require.Equal(t, 42, evaluation.Result.Value) + + require.NoError(t, m.Start(ctx)) + require.NoError(t, otherProtocol.Close()) + <-otherSurface.Done() + capture := func(marker string) string { + t.Helper() + time.Sleep(500 * time.Millisecond) + raw := evaluateNetworkScript(t, ctx, driver, driverSession, fmt.Sprintf(`(async () => { + const response = await fetch('/post?marker=%s', {method: 'POST', body: %q}); + return {status: response.status, body: await response.text()}; + })()`, marker, marker)) + var result struct { + Status int `json:"status"` + Body string `json:"body"` + } + require.NoError(t, json.Unmarshal(raw, &result)) + require.Equal(t, 200, result.Status) + require.Equal(t, marker, result.Body) + event := waitForNetworkURL(t, ec, EventNetworkResponse, stub.URL+"/post?marker="+marker) + var response oapi.BrowserNetworkResponseEventData + require.NoError(t, json.Unmarshal(event.Data, &response)) + require.NotNil(t, response.Body) + require.Equal(t, marker, *response.Body) + return response.SessionId + } + before := capture("before-reconnect") + require.NotEqual(t, otherSession, before) + checkpoint := ec.checkpoint() + upstream.notifyRestart(browserWS) + ec.waitForNew(t, EventMonitorReconnected, checkpoint, 5*time.Second) + after := capture("after-reconnect") + require.NotEmpty(t, after) + require.NotEqual(t, before, after, "reconnect must use fresh telemetry sessions") +} diff --git a/server/lib/cdpmonitor/monitor.go b/server/lib/cdpmonitor/monitor.go index 9ae4dacb0..574f4b908 100644 --- a/server/lib/cdpmonitor/monitor.go +++ b/server/lib/cdpmonitor/monitor.go @@ -9,7 +9,8 @@ import ( "sync/atomic" "time" - "github.com/coder/websocket" + "github.com/kernel/kernel-images/server/lib/browsersurface" + "github.com/kernel/kernel-images/server/lib/cdpclient" "github.com/kernel/kernel-images/server/lib/events" oapi "github.com/kernel/kernel-images/server/lib/oapi" ) @@ -24,64 +25,54 @@ type UpstreamProvider interface { // this to TelemetrySession.Publish; cdpmonitor itself ignores the returns. type PublishFunc func(ev events.Event) (events.Envelope, bool) -const wsReadLimit = 8 * 1024 * 1024 +type monitorConnection struct { + protocol *cdpclient.Client + surface *browsersurface.Tracker + ctx context.Context + cancel context.CancelFunc + done chan struct{} +} -// Monitor manages a CDP WebSocket connection with auto-attach session fan-out. -// WebSocket concurrency: coder/websocket guarantees that one concurrent Read -// and one concurrent Write are safe. The readLoop holds the sole Read; all -// writes go through send, which serialises them with conn.Write's internal -// lock. No external write mutex is needed. +// Monitor owns its CDP connection and browser-surface tracker independently of +// other consumers such as WebMCP. A new pair is created on each reconnect. type Monitor struct { upstreamMgr UpstreamProvider publish PublishFunc displayNum int log *slog.Logger - // lifeMu serializes Start, Stop, and restartReadLoop to prevent races on - // conn, lifecycleCtx, cancel, and done. lifeMu sync.Mutex - conn *websocket.Conn - - nextID atomic.Int64 - pendMu sync.Mutex - pending map[int64]chan cdpMessage + conn *monitorConnection sessionsMu sync.RWMutex sessions map[string]targetInfo // sessionID → targetInfo mainSessionID atomic.Value // string; set on first top-level frameNavigated, cleared on reconnect pendReqMu sync.Mutex - pendingRequests map[string]networkReqState // requestId → networkReqState + pendingRequests map[networkRequestKey]networkReqState computedStates map[string]*computedState // sessionID → state machine; guarded by sessionsMu - lastScreenshotAt atomic.Int64 // unix millis of last capture - screenshotInFlight atomic.Bool // true while a captureScreenshot goroutine is running - screenshotFn func(ctx context.Context, displayNum int) ([]byte, error) // nil → real ffmpeg - screenshotEnabled func() bool // nil → always capture; gates ffmpeg on the screenshot category + lastScreenshotAt atomic.Int64 + screenshotInFlight atomic.Bool + screenshotFn func(ctx context.Context, displayNum int) ([]byte, error) + screenshotEnabled func() bool - // bindingRateMu guards bindingLastSeen. bindingRateMu sync.Mutex - bindingLastSeen map[string]time.Time // sessionID → last accepted binding event time + bindingLastSeen map[string]time.Time - // proxyRateMu guards proxyLastEmit. Entries are never pruned per-session - // (mirrors bindingLastSeen); the map stays bounded because proxy errors are - // rare and keys are limited to valid enum codes plus resource type. proxyRateMu sync.Mutex - proxyLastEmit map[string]time.Time // sessionID:code:resourceType → last accepted proxy_error emit time + proxyLastEmit map[string]time.Time - // asyncWg tracks all goroutines except readLoop (which is tracked via done). - // subscribeToUpstream and sweepPendingRequests are included so Stop() can - // wait for them to exit before returning. + // Lifecycle workers survive reconnects. Capture work is drained before + // replacing a connection so stale sessions cannot write into the next one. asyncWg sync.WaitGroup - restartMu sync.Mutex // serializes handleUpstreamRestart to prevent overlapping reconnects + captureWg sync.WaitGroup + restartMu sync.Mutex - lifecycleCtx context.Context // cancelled on Stop() + lifecycleCtx context.Context cancel context.CancelFunc - done chan struct{} - readReady chan struct{} // closed when readLoop has started reading - - running atomic.Bool + running atomic.Bool } // New creates a Monitor. displayNum is the X display for ffmpeg screenshots. @@ -95,12 +86,11 @@ func New(upstreamMgr UpstreamProvider, publish PublishFunc, displayNum int, log screenshotEnabled: screenshotEnabled, sessions: make(map[string]targetInfo), computedStates: make(map[string]*computedState), - pending: make(map[int64]chan cdpMessage), - pendingRequests: make(map[string]networkReqState), + pendingRequests: make(map[networkRequestKey]networkReqState), bindingLastSeen: make(map[string]time.Time), proxyLastEmit: make(map[string]time.Time), + lifecycleCtx: context.Background(), } - m.lifecycleCtx = context.Background() m.mainSessionID.Store(mainSessionUnset) return m } @@ -113,83 +103,137 @@ func (m *Monitor) IsRunning() bool { // Start begins CDP capture. Restarts if already running. // Not concurrency-safe; callers must serialize Start calls. func (m *Monitor) Start(ctx context.Context) error { - m.Stop() // no-op if not running - + m.Stop() devtoolsURL := m.upstreamMgr.Current() if devtoolsURL == "" { return fmt.Errorf("cdpmonitor: no DevTools URL available") } - ctx, cancel := context.WithCancel(ctx) - - conn, _, err := websocket.Dial(ctx, devtoolsURL, nil) - if err != nil { - cancel() - return fmt.Errorf("cdpmonitor: dial %s: %w", devtoolsURL, err) - } - conn.SetReadLimit(wsReadLimit) - m.lifeMu.Lock() - m.conn = conn - m.lifecycleCtx = ctx - m.cancel = cancel - m.done = make(chan struct{}) - m.readReady = make(chan struct{}) + m.lifecycleCtx, m.cancel = ctx, cancel m.lifeMu.Unlock() - + if err := m.openConnection(ctx, devtoolsURL); err != nil { + cancel() + return err + } m.running.Store(true) m.log.Info("cdpmonitor: started", "url", devtoolsURL) - - go m.readLoop(ctx) m.asyncWg.Go(func() { m.subscribeToUpstream(ctx) }) m.asyncWg.Go(func() { m.sweepPendingRequests(ctx) }) - m.asyncWg.Go(func() { m.initSession(ctx) }) - return nil } -// Stop cancels the context and waits for goroutines to exit. +// Stop cancels the lifecycle and waits for both connection and capture work. func (m *Monitor) Stop() { - if !m.running.Swap(false) { - m.lifeMu.Lock() - cancel := m.cancel - m.lifeMu.Unlock() - if cancel != nil { - cancel() - } - m.asyncWg.Wait() - return + wasRunning := m.running.Swap(false) + if wasRunning { + m.log.Info("cdpmonitor: stopping") } - m.log.Info("cdpmonitor: stopping") - m.lifeMu.Lock() if m.cancel != nil { m.cancel() } - done := m.done m.lifeMu.Unlock() - - if done != nil { - <-done + m.asyncWg.Wait() + m.restartMu.Lock() + defer m.restartMu.Unlock() + m.closeConnection() + m.clearState() + if wasRunning { + m.log.Info("cdpmonitor: stopped") } +} - // Wait for all in-flight async goroutines (fetchResponseBody, enableDomains, - // screenshots) to finish before closing the connection they may be writing to. - m.asyncWg.Wait() +func (m *Monitor) openConnection(ctx context.Context, devtoolsURL string) error { + protocol, err := cdpclient.DialWithEvents(ctx, devtoolsURL) + if err != nil { + return fmt.Errorf("cdpmonitor: dial %s: %w", devtoolsURL, err) + } + ctx, cancel := context.WithCancel(ctx) + conn := &monitorConnection{ + protocol: protocol, + surface: browsersurface.New(protocol, + browsersurface.WithAdditionalTargets("worker", "shared_worker", "service_worker", "background_page"), + browsersurface.WithoutLocations(), + ), + ctx: ctx, cancel: cancel, done: make(chan struct{}), + } + eventCh, unsubscribe := conn.surface.Subscribe() + m.lifeMu.Lock() + m.conn = conn + m.lifeMu.Unlock() + go func() { + defer close(conn.done) + defer unsubscribe() + defer cancel() + for { + select { + case <-ctx.Done(): + return + case event, ok := <-eventCh: + if !ok { + return + } + m.handleSurfaceEvent(conn, event) + } + } + }() + m.captureWg.Go(func() { + if err := conn.surface.Start(ctx); err != nil && ctx.Err() == nil { + m.log.Error("cdpmonitor: browser surface discovery failed", "err", err) + data, _ := json.Marshal(oapi.BrowserMonitorInitFailedEventData{Step: "browsersurface.Start"}) + m.publish(events.Event{ + Ts: time.Now().UnixMicro(), Type: EventMonitorInitFailed, Category: events.Monitor, + Source: oapi.BrowserEventSource{Kind: oapi.LocalProcess}, Data: data, + }) + } + }) + return nil +} +func (m *Monitor) closeConnection() { m.lifeMu.Lock() - if m.conn != nil { - _ = m.conn.Close(websocket.StatusNormalClosure, "stopped") - m.conn = nil + conn := m.conn + m.lifeMu.Unlock() + if conn != nil { + conn.cancel() + _ = conn.protocol.Close() + <-conn.done + <-conn.surface.Done() } + m.captureWg.Wait() + m.lifeMu.Lock() + m.conn = nil m.lifeMu.Unlock() +} - m.clearState() - m.log.Info("cdpmonitor: stopped") +func (m *Monitor) handleSurfaceEvent(conn *monitorConnection, event browsersurface.Event) { + switch event.Kind { + case browsersurface.EventSessionAttached: + if !conn.surface.SessionExists(event.SessionID) { + return + } + m.handleAttachedToTarget(conn.ctx, cdpTargetAttachedToTargetParams{ + SessionID: event.SessionID, + TargetInfo: cdpTargetTargetInfo{ + TargetID: event.Target.ID, Type: event.Target.Type, + URL: event.Target.URL, Title: event.Target.Title, OpenerID: event.Target.OpenerID, + }, + }) + case browsersurface.EventSessionRemoved: + m.handleDetachedFromTarget(cdpTargetDetachedFromTargetParams{SessionID: event.SessionID}) + case browsersurface.EventProtocol: + // Attachment lifecycle is emitted once by the tracker, including targets + // discovered through enumeration whose attach response arrived first. + if event.Message.Method == "Target.attachedToTarget" || event.Message.Method == "Target.detachedFromTarget" { + return + } + m.dispatchEvent(cdpMessage{ + Method: event.Message.Method, Params: event.Message.Params, SessionID: event.Message.SessionID, + }) + } } -// clearState resets sessions, pending requests, and computed state. -// It also fails all in-flight send() calls so their goroutines are unblocked. func (m *Monitor) clearState() { m.sessionsMu.Lock() prev := m.computedStates @@ -200,29 +244,21 @@ func (m *Monitor) clearState() { cs.stop() } m.mainSessionID.Store(mainSessionUnset) - m.pendReqMu.Lock() - m.pendingRequests = make(map[string]networkReqState) + m.pendingRequests = make(map[networkRequestKey]networkReqState) m.pendReqMu.Unlock() - m.bindingRateMu.Lock() m.bindingLastSeen = make(map[string]time.Time) m.bindingRateMu.Unlock() - m.proxyRateMu.Lock() m.proxyLastEmit = make(map[string]time.Time) m.proxyRateMu.Unlock() - - m.failPendingCommands() } const pendingRequestTTL = 5 * time.Minute const sweepInterval = 1 * time.Minute -// sweepPendingRequests periodically evicts networkReqState entries that have -// been in the map for longer than pendingRequestTTL. This bounds map growth on -// long-lived SPAs where loadingFinished never arrives (e.g. tabs closed mid-flight). -// It exits when ctx is cancelled (Stop/reconnect). +// sweepPendingRequests bounds requests whose terminal event never arrives. func (m *Monitor) sweepPendingRequests(ctx context.Context) { ticker := time.NewTicker(sweepInterval) defer ticker.Stop() @@ -249,7 +285,6 @@ func (m *Monitor) sweepPendingRequests(ctx context.Context) { } } -// computedFor returns the computedState for the given sessionID, or nil if none exists. func (m *Monitor) computedFor(sessionID string) *computedState { m.sessionsMu.RLock() cs := m.computedStates[sessionID] @@ -257,233 +292,26 @@ func (m *Monitor) computedFor(sessionID string) *computedState { return cs } -// failPendingCommands unblocks all in-flight send() calls by delivering an -// error response. This prevents goroutine leaks when the connection is torn -// down during reconnect. -func (m *Monitor) failPendingCommands() { - m.pendMu.Lock() - old := m.pending - m.pending = make(map[int64]chan cdpMessage) - m.pendMu.Unlock() - - disconnectErr := &cdpError{Code: -1, Message: "connection closed"} - for _, ch := range old { - select { - case ch <- cdpMessage{Error: disconnectErr}: - default: - } - } -} - -// readLoop reads CDP messages, routing responses to pending callers and dispatching events. -// On read error (WS drop) this goroutine returns. Reconnection is driven by -// subscribeToUpstream: the UpstreamProvider always pushes a fresh devtools URL -// when the browser process restarts, so same-URL redial is not needed here. -func (m *Monitor) readLoop(ctx context.Context) { - m.lifeMu.Lock() - done := m.done - conn := m.conn - readReady := m.readReady - m.lifeMu.Unlock() - defer close(done) - - if conn == nil { - return - } - - // Signal that readLoop is ready to receive responses. - close(readReady) - - for { - _, b, err := conn.Read(ctx) - if err != nil { - if ctx.Err() == nil { - m.log.Warn("cdpmonitor: read loop exiting on unexpected error", "err", err) - } - return - } - - var msg cdpMessage - if err := json.Unmarshal(b, &msg); err != nil { - m.log.Warn("cdpmonitor: dropping malformed CDP message", "err", err) - continue - } - - if msg.ID != nil { - m.pendMu.Lock() - ch, ok := m.pending[*msg.ID] - m.pendMu.Unlock() - if ok { - select { - case ch <- msg: - default: - // send() already timed out and deregistered; discard. - } - } - continue - } - - m.dispatchEvent(msg) - } -} - const sendTimeout = 30 * time.Second -// send issues a CDP command and blocks until the response arrives. -// A 30 s deadline is applied so a non-responsive Chrome cannot stall -// callers indefinitely; the caller's own deadline (if shorter) wins. func (m *Monitor) send(ctx context.Context, method string, params any, sessionID string) (json.RawMessage, error) { ctx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() - - id := m.nextID.Add(1) - - var rawParams json.RawMessage - if params != nil { - b, err := json.Marshal(params) - if err != nil { - return nil, fmt.Errorf("marshal params: %w", err) - } - rawParams = b - } - - req := cdpMessage{ID: &id, Method: method, Params: rawParams, SessionID: sessionID} - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("marshal request: %w", err) + if err := ctx.Err(); err != nil { + return nil, err } - - ch := make(chan cdpMessage, 1) - m.pendMu.Lock() - m.pending[id] = ch - m.pendMu.Unlock() - defer func() { - m.pendMu.Lock() - delete(m.pending, id) - m.pendMu.Unlock() - }() - m.lifeMu.Lock() conn := m.conn m.lifeMu.Unlock() if conn == nil { return nil, fmt.Errorf("cdpmonitor: connection not open") } - - // coder/websocket allows concurrent Read + Write on the same Conn. - if err := conn.Write(ctx, websocket.MessageText, reqBytes); err != nil { - return nil, fmt.Errorf("write: %w", err) - } - - select { - case resp := <-ch: - if resp.Error != nil { - return nil, resp.Error - } - return resp.Result, nil - case <-ctx.Done(): - return nil, ctx.Err() - } -} - -// initSession enables CDP domains, injects the interaction-tracking script, -// and manually attaches to any targets already open when the monitor started. -// It waits for readLoop to be ready before sending any commands. -func (m *Monitor) initSession(ctx context.Context) { - m.lifeMu.Lock() - readReady := m.readReady - m.lifeMu.Unlock() - select { - case <-readReady: - case <-ctx.Done(): - return - } - - if _, err := m.send(ctx, cdpMethodSetAutoAttach, map[string]any{ - "autoAttach": true, - "waitForDebuggerOnStart": false, - "flatten": true, - }, ""); err != nil && ctx.Err() == nil { - // Without auto-attach the monitor will never see new targets: treat as fatal. - m.log.Error("cdpmonitor: Target.setAutoAttach failed — monitor will not observe new targets", "err", err) - initFailedData, _ := json.Marshal(oapi.BrowserMonitorInitFailedEventData{ - Step: cdpMethodSetAutoAttach, - }) - m.publish(events.Event{ - Ts: time.Now().UnixMicro(), - Type: EventMonitorInitFailed, - Category: events.Monitor, - Source: oapi.BrowserEventSource{Kind: oapi.LocalProcess}, - Data: initFailedData, - }) - return - } - - m.attachExistingTargets(ctx) -} - -// attachExistingTargets fetches all open targets and attaches to any that are -// not already tracked. This catches pages that were open before Start() was called. -func (m *Monitor) attachExistingTargets(ctx context.Context) { - result, err := m.send(ctx, "Target.getTargets", nil, "") - if err != nil { - return - } - var resp struct { - TargetInfos []cdpTargetTargetInfo `json:"targetInfos"` - } - if err := json.Unmarshal(result, &resp); err != nil { - return - } - m.sessionsMu.RLock() - attached := make(map[string]bool, len(m.sessions)) - for _, info := range m.sessions { - attached[info.targetID] = true - } - m.sessionsMu.RUnlock() - - for _, ti := range resp.TargetInfos { - if ti.Type != targetTypePage || attached[ti.TargetID] { - continue - } - targetID := ti.TargetID - m.asyncWg.Go(func() { - _, _ = m.send(ctx, "Target.attachToTarget", map[string]any{ - "targetId": targetID, - "flatten": true, - }, "") - }) - } + return conn.protocol.Send(ctx, method, params, sessionID) } -// restartReadLoop waits for the current readLoop to exit, then starts a new one. -// Returns false if the context was cancelled before the restart completed. -func (m *Monitor) restartReadLoop(ctx context.Context) bool { - m.lifeMu.Lock() - done := m.done - m.lifeMu.Unlock() - - // Wait for old readLoop, but bail if context is cancelled (e.g. Stop called). - select { - case <-done: - case <-ctx.Done(): - return false - } - - m.lifeMu.Lock() - m.done = make(chan struct{}) - m.readReady = make(chan struct{}) - m.lifeMu.Unlock() - - go m.readLoop(ctx) - return true -} - -// subscribeToUpstream reconnects with backoff on Chrome restarts, publishing disconnect/reconnect events. func (m *Monitor) subscribeToUpstream(ctx context.Context) { ch, cancel := m.upstreamMgr.Subscribe() defer cancel() - for { select { case <-ctx.Done(): @@ -497,84 +325,40 @@ func (m *Monitor) subscribeToUpstream(ctx context.Context) { } } -// handleUpstreamRestart tears down the old connection, reconnects with backoff, -// and re-initializes the CDP session. Serialized by restartMu to prevent -// overlapping reconnects from rapid successive Chrome restarts. func (m *Monitor) handleUpstreamRestart(ctx context.Context, newURL string) { m.restartMu.Lock() defer m.restartMu.Unlock() - if ctx.Err() != nil { return } - disconnectedData, _ := json.Marshal(oapi.BrowserMonitorDisconnectedEventData{ - Reason: oapi.ChromeRestarted, - }) + data, _ := json.Marshal(oapi.BrowserMonitorDisconnectedEventData{Reason: oapi.ChromeRestarted}) m.publish(events.Event{ - Ts: time.Now().UnixMicro(), - Type: EventMonitorDisconnected, - Category: events.Monitor, - Source: oapi.BrowserEventSource{Kind: oapi.LocalProcess}, - Data: disconnectedData, + Ts: time.Now().UnixMicro(), Type: EventMonitorDisconnected, Category: events.Monitor, + Source: oapi.BrowserEventSource{Kind: oapi.LocalProcess}, Data: data, }) - startReconnect := time.Now() - - m.lifeMu.Lock() - if m.conn != nil { - _ = m.conn.Close(websocket.StatusNormalClosure, "reconnecting") - m.conn = nil - } - m.lifeMu.Unlock() - + m.closeConnection() + m.clearState() if !m.reconnectWithBackoff(ctx, newURL) { - // Context cancelled means Stop() was called, not a failure. if ctx.Err() == nil { - // Cancel the lifecycle context before setting running=false so that - // goroutines blocked on ctx.Done() begin exiting. If we set - // running=false first, a concurrent Stop() call returns immediately - // without cancelling, permanently orphaning those goroutines in asyncWg. m.lifeMu.Lock() - if m.cancel != nil { - m.cancel() - } + m.cancel() m.lifeMu.Unlock() - m.clearState() m.running.Store(false) - reconnectFailedData, _ := json.Marshal(oapi.BrowserMonitorReconnectFailedEventData{ - Reason: oapi.ReconnectExhausted, - }) + data, _ := json.Marshal(oapi.BrowserMonitorReconnectFailedEventData{Reason: oapi.ReconnectExhausted}) m.publish(events.Event{ - Ts: time.Now().UnixMicro(), - Type: EventMonitorReconnectFailed, - Category: events.Monitor, - Source: oapi.BrowserEventSource{Kind: oapi.LocalProcess}, - Data: reconnectFailedData, + Ts: time.Now().UnixMicro(), Type: EventMonitorReconnectFailed, Category: events.Monitor, + Source: oapi.BrowserEventSource{Kind: oapi.LocalProcess}, Data: data, }) } return } - - // restartReadLoop waits for the old readLoop to exit before returning, - // so clearState runs only after the old loop has stopped touching shared state. - if !m.restartReadLoop(ctx) { - return - } - m.clearState() - - m.asyncWg.Go(func() { m.initSession(ctx) }) - reconnectDurationMs := time.Since(startReconnect).Milliseconds() - m.log.Info("cdpmonitor: reconnected", "url", newURL, "duration_ms", reconnectDurationMs) - - reconnectedData, _ := json.Marshal(oapi.BrowserMonitorReconnectedEventData{ - ReconnectDurationMs: reconnectDurationMs, - }) + durationMs := time.Since(startReconnect).Milliseconds() + m.log.Info("cdpmonitor: reconnected", "url", newURL, "duration_ms", durationMs) + data, _ = json.Marshal(oapi.BrowserMonitorReconnectedEventData{ReconnectDurationMs: durationMs}) m.publish(events.Event{ - Ts: time.Now().UnixMicro(), - Type: EventMonitorReconnected, - Category: events.Monitor, - Source: oapi.BrowserEventSource{Kind: oapi.LocalProcess}, - Data: reconnectedData, + Ts: time.Now().UnixMicro(), Type: EventMonitorReconnected, Category: events.Monitor, + Source: oapi.BrowserEventSource{Kind: oapi.LocalProcess}, Data: data, }) } @@ -587,13 +371,11 @@ var reconnectBackoffs = []time.Duration{ 2 * time.Second, } -// reconnectWithBackoff attempts to dial newURL up to maxReconnectAttempts times with exponential backoff. func (m *Monitor) reconnectWithBackoff(ctx context.Context, newURL string) bool { for attempt := range maxReconnectAttempts { if ctx.Err() != nil { return false } - if attempt > 0 { idx := min(attempt-1, len(reconnectBackoffs)-1) select { @@ -602,17 +384,10 @@ func (m *Monitor) reconnectWithBackoff(ctx context.Context, newURL string) bool case <-time.After(reconnectBackoffs[idx]): } } - - conn, _, err := websocket.Dial(ctx, newURL, nil) - if err != nil { + if err := m.openConnection(ctx, newURL); err != nil { m.log.Warn("cdpmonitor: reconnect attempt failed", "attempt", attempt+1, "max_attempts", maxReconnectAttempts, "url", newURL, "err", err) continue } - conn.SetReadLimit(wsReadLimit) - - m.lifeMu.Lock() - m.conn = conn - m.lifeMu.Unlock() return true } return false diff --git a/server/lib/cdpmonitor/monitor_test.go b/server/lib/cdpmonitor/monitor_test.go index 70eda67d3..5e9a22f42 100644 --- a/server/lib/cdpmonitor/monitor_test.go +++ b/server/lib/cdpmonitor/monitor_test.go @@ -123,41 +123,30 @@ func TestScreenshot(t *testing.T) { }) } -// TestFailPendingCommandsUnblocksSend verifies that clearState (called during -// reconnect) unblocks any goroutine blocked in send() by delivering an error. -func TestFailPendingCommandsUnblocksSend(t *testing.T) { - ec := newEventCollector() - upstream := newTestUpstream("ws://127.0.0.1:0") - m := New(upstream, ec.publishFn(), 0, discardLogger, nil) - - // Pre-register a fake pending command channel as if send() had registered it. - id := int64(42) - ch := make(chan cdpMessage, 1) - m.pendMu.Lock() - m.pending[id] = ch - m.pendMu.Unlock() - - // failPendingCommands should deliver an error message to ch without blocking. - done := make(chan struct{}) +func TestStopUnblocksPendingSend(t *testing.T) { + srv := newTestServer(t) + defer srv.close() + m := New(newTestUpstream(srv.wsURL()), newEventCollector().publishFn(), 0, discardLogger, nil) + require.NoError(t, m.Start(context.Background())) + defer m.Stop() + srv.readFromMonitor(t, 2*time.Second) + + done := make(chan error, 1) go func() { - m.failPendingCommands() - close(done) + _, err := m.send(context.Background(), "Network.enable", nil, "session") + done <- err }() - + require.Equal(t, "Network.enable", srv.readFromMonitor(t, 2*time.Second).Method) + m.Stop() select { - case msg := <-ch: - require.NotNil(t, msg.Error, "expected error response from failPendingCommands") - assert.Equal(t, -1, msg.Error.Code) + case err := <-done: + require.Error(t, err) case <-time.After(2 * time.Second): - t.Fatal("failPendingCommands did not unblock the pending channel") + t.Fatal("Stop did not unblock the pending command") } - <-done } -// TestInitSessionAutoAttachFailure verifies that a monitor_init_failed event is -// published (and the monitor logs the failure) when Target.setAutoAttach returns -// an error. -func TestInitSessionAutoAttachFailure(t *testing.T) { +func TestInitSessionDiscoveryFailure(t *testing.T) { srv := newTestServer(t) defer srv.close() @@ -171,7 +160,7 @@ func TestInitSessionAutoAttachFailure(t *testing.T) { defer close(stopResponder) go listenAndRespond(srv, stopResponder, func(msg cdpMessage) any { - if msg.Method == "Target.setAutoAttach" { + if msg.Method == "Target.setDiscoverTargets" { return map[string]any{ "id": msg.ID, "error": map[string]any{"code": -32601, "message": "Method not found"}, @@ -183,7 +172,7 @@ func TestInitSessionAutoAttachFailure(t *testing.T) { ec.waitFor(t, EventMonitorInitFailed, 3*time.Second) } -func TestAutoAttach(t *testing.T) { +func TestSurfaceDiscovery(t *testing.T) { srv := newTestServer(t) defer srv.close() @@ -194,17 +183,21 @@ func TestAutoAttach(t *testing.T) { defer m.Stop() msg := srv.readFromMonitor(t, 3*time.Second) - assert.Equal(t, "Target.setAutoAttach", msg.Method) + assert.Equal(t, "Target.setDiscoverTargets", msg.Method) var params struct { - AutoAttach bool `json:"autoAttach"` - WaitForDebuggerOnStart bool `json:"waitForDebuggerOnStart"` - Flatten bool `json:"flatten"` + Discover bool `json:"discover"` + Filter []struct { + Type string `json:"type"` + } `json:"filter"` } require.NoError(t, json.Unmarshal(msg.Params, ¶ms)) - assert.True(t, params.AutoAttach) - assert.False(t, params.WaitForDebuggerOnStart) - assert.True(t, params.Flatten) + assert.True(t, params.Discover) + var types []string + for _, filter := range params.Filter { + types = append(types, filter.Type) + } + assert.ElementsMatch(t, []string{"page", "iframe", "worker", "shared_worker", "service_worker", "background_page"}, types) stopResponder := make(chan struct{}) go listenAndRespond(srv, stopResponder, nil) diff --git a/server/lib/cdpmonitor/network_chrome_e2e_test.go b/server/lib/cdpmonitor/network_chrome_e2e_test.go index 775251a86..f9e8cf44c 100644 --- a/server/lib/cdpmonitor/network_chrome_e2e_test.go +++ b/server/lib/cdpmonitor/network_chrome_e2e_test.go @@ -195,6 +195,7 @@ func TestNetworkCaptureAcrossFrames(t *testing.T) { require.JSONEq(t, string(body), *request.PostData) require.Equal(t, frame.target, request.TargetId) require.Equal(t, frame.targetType, string(request.TargetType)) + require.True(t, request.TargetType.Valid()) require.NotEmpty(t, request.SessionId) require.NotNil(t, request.FrameId) require.NotEmpty(t, *request.FrameId) diff --git a/server/lib/cdpmonitor/screenshot.go b/server/lib/cdpmonitor/screenshot.go index 87809aee0..49ca51f16 100644 --- a/server/lib/cdpmonitor/screenshot.go +++ b/server/lib/cdpmonitor/screenshot.go @@ -42,7 +42,7 @@ func (m *Monitor) tryScreenshot(ctx context.Context, sourceEvent, sessionID stri if cs := m.computedFor(sessionID); cs != nil { _, navMeta = cs.navSnapshot() } - m.asyncWg.Go(func() { + m.captureWg.Go(func() { defer m.screenshotInFlight.Store(false) m.captureScreenshot(ctx, sourceEvent, navMeta) }) diff --git a/server/lib/cdpmonitor/session_test.go b/server/lib/cdpmonitor/session_test.go new file mode 100644 index 000000000..65c1235f0 --- /dev/null +++ b/server/lib/cdpmonitor/session_test.go @@ -0,0 +1,84 @@ +package cdpmonitor + +import ( + "context" + "encoding/json" + "testing" + + "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +func TestNetworkRequestIDsAreSessionScoped(t *testing.T) { + srv := newTestServer(t) + defer srv.close() + m, ec, cleanup := startMonitor(t, srv, func(msg cdpMessage) any { + if msg.Method == "Network.getResponseBody" { + return map[string]any{"id": msg.ID, "result": map[string]any{"body": msg.SessionID}} + } + return nil + }) + defer cleanup() + + for _, sessionID := range []string{"page-session", "iframe-session"} { + m.handleNetworkRequest(cdpNetworkRequestWillBeSentParams{ + RequestID: "same-id", Type: "Fetch", + Request: cdpNetworkRequest{Method: "POST", URL: "https://example.com/" + sessionID, PostData: sessionID}, + }, sessionID) + m.handleResponseReceived(cdpNetworkResponseReceivedParams{ + RequestID: "same-id", Response: cdpNetworkResponse{Status: 200, MimeType: "text/plain"}, + }, sessionID) + } + m.pendReqMu.Lock() + pending := len(m.pendingRequests) + m.pendReqMu.Unlock() + require.Equal(t, 2, pending) + + m.handleLoadingFinished(context.Background(), cdpNetworkLoadingFinishedParams{RequestID: "same-id"}, "page-session") + m.handleLoadingFailed(cdpNetworkLoadingFailedParams{RequestID: "same-id", Canceled: true, ErrorText: "net::ERR_ABORTED"}, "iframe-session") + + responseEvent := waitForNetworkURL(t, ec, EventNetworkResponse, "https://example.com/page-session") + var response oapi.BrowserNetworkResponseEventData + require.NoError(t, json.Unmarshal(responseEvent.Data, &response)) + require.Equal(t, "page-session", response.SessionId) + require.NotNil(t, response.Body) + require.Equal(t, "page-session", *response.Body) + + failedEvent := waitForNetworkURL(t, ec, EventNetworkLoadingFailed, "https://example.com/iframe-session") + var failure oapi.BrowserNetworkLoadingFailedEventData + require.NoError(t, json.Unmarshal(failedEvent.Data, &failure)) + require.Equal(t, "iframe-session", failure.SessionId) + require.True(t, failure.Canceled) + m.pendReqMu.Lock() + pending = len(m.pendingRequests) + m.pendReqMu.Unlock() + require.Zero(t, pending) +} + +func TestDetachClearsOnlyThatSessionsRequests(t *testing.T) { + m, _ := newComputedMonitor(t) + for _, sessionID := range []string{"s1", "child"} { + m.handleNetworkRequest(cdpNetworkRequestWillBeSentParams{ + RequestID: "same-id", Request: cdpNetworkRequest{Method: "GET", URL: "https://example.com/"}, + }, sessionID) + } + m.handleDetachedFromTarget(cdpTargetDetachedFromTargetParams{SessionID: "child"}) + m.pendReqMu.Lock() + _, parent := m.pendingRequests[networkRequestKey{"s1", "same-id"}] + _, child := m.pendingRequests[networkRequestKey{"child", "same-id"}] + m.pendReqMu.Unlock() + require.True(t, parent) + require.False(t, child) +} + +func TestOOPIFLocalRootDoesNotBecomeMainPage(t *testing.T) { + m, _ := newComputedMonitor(t) + navigateMonitor(m, "https://example.com/") + m.sessionsMu.Lock() + m.sessions["child"] = targetInfo{targetID: "child-target", targetType: "iframe"} + m.sessionsMu.Unlock() + m.handleFrameNavigated(cdpPageFrameNavigatedParams{ + Frame: cdpPageFrame{ID: "child-target", URL: "https://other.example/"}, + }, "child") + require.Equal(t, "s1", m.mainSessionID.Load()) +} diff --git a/server/lib/cdpmonitor/types.go b/server/lib/cdpmonitor/types.go index 7dde3dea7..8c422973f 100644 --- a/server/lib/cdpmonitor/types.go +++ b/server/lib/cdpmonitor/types.go @@ -70,8 +70,6 @@ const ( timelineEventLCP = "largest-contentful-paint" ) -const cdpMethodSetAutoAttach = "Target.setAutoAttach" - // CDP target type for browser pages (as opposed to workers, iframes, etc.). const targetTypePage = "page" @@ -104,6 +102,11 @@ type cdpMessage struct { Error *cdpError `json:"error,omitempty"` } +type networkRequestKey struct { + sessionID string + requestID string +} + // networkReqState holds request + response metadata until loadingFinished. type networkReqState struct { sessionID string diff --git a/server/lib/cdpmonitor/workers_chrome_e2e_test.go b/server/lib/cdpmonitor/workers_chrome_e2e_test.go new file mode 100644 index 000000000..333da7108 --- /dev/null +++ b/server/lib/cdpmonitor/workers_chrome_e2e_test.go @@ -0,0 +1,148 @@ +package cdpmonitor + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +func TestNetworkCaptureFromWorkers(t *testing.T) { + if os.Getenv("KERNEL_CDPMONITOR_CHROME_E2E") == "" { + t.Skip("set KERNEL_CDPMONITOR_CHROME_E2E=1 to run real-Chromium worker tests") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + const replyScript = `async function reply(marker, port) { + try { + const response = await fetch('/post?marker=' + marker, { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({marker}) + }); + port.postMessage({status: response.status, body: await response.text()}); + } catch (error) { port.postMessage({error: error.message}); } + }` + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/post" { + w.Header().Set("Content-Type", "application/json") + _, _ = io.Copy(w, r.Body) + return + } + w.Header().Set("Content-Type", "text/javascript") + switch r.URL.Path { + case "/worker.js": + fmt.Fprint(w, replyScript+`; self.onmessage = event => reply(event.data, self);`) + case "/shared.js": + fmt.Fprint(w, replyScript+`; self.onconnect = event => { + const port = event.ports[0]; port.onmessage = event => reply(event.data, port); port.start(); + };`) + case "/service.js": + fmt.Fprint(w, replyScript+`; + self.addEventListener('install', event => event.waitUntil(self.skipWaiting())); + self.addEventListener('activate', event => event.waitUntil(self.clients.claim())); + self.addEventListener('message', event => event.waitUntil(reply(event.data, event.ports[0])));`) + default: + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "workers") + } + })) + defer stub.Close() + browserWS := launchChromium(t, ctx, findChromium(t)) + cdp := dialCDP(t, ctx, browserWS) + defer cdp.close() + ec := newEventCollector() + m := New(&staticUpstream{url: browserWS}, ec.publishFn(), 99, discardLogger, nil) + require.NoError(t, m.Start(ctx)) + defer m.Stop() + targetID := cdp.call(t, ctx, "", "Target.createTarget", map[string]any{"url": stub.URL}).targetID(t) + sessionID := cdp.call(t, ctx, "", "Target.attachToTarget", map[string]any{"targetId": targetID, "flatten": true}).sessionID(t) + require.Eventually(t, func() bool { + return cdp.evalBool(ctx, sessionID, `document.readyState === 'complete' && window.__kernelEventInjected === true`) + }, 5*time.Second, 50*time.Millisecond) + + for _, worker := range []struct{ targetType, path, create, request string }{ + {"worker", "/worker.js", `window.testWorker = new Worker('/worker.js')`, `testWorker.onmessage = event => resolve(event.data); testWorker.postMessage(marker);`}, + {"shared_worker", "/shared.js", `window.sharedWorker = new SharedWorker('/shared.js'); sharedWorker.port.start()`, `sharedWorker.port.onmessage = event => resolve(event.data); sharedWorker.port.postMessage(marker);`}, + {"service_worker", "/service.js", `await navigator.serviceWorker.register('/service.js'); window.serviceWorker = (await navigator.serviceWorker.ready).active`, `const channel = new MessageChannel(); channel.port1.onmessage = event => resolve(event.data); serviceWorker.postMessage(marker, [channel.port2]);`}, + } { + t.Run(worker.targetType, func(t *testing.T) { + evaluateNetworkScript(t, ctx, cdp, sessionID, "(async () => {"+worker.create+"; return true;})()") + require.Eventually(t, func() bool { + raw, err := cdp.roundtrip(ctx, "", "Target.getTargets", nil) + if err != nil { + return false + } + var result struct { + Result struct { + Targets []cdpTargetTargetInfo `json:"targetInfos"` + } `json:"result"` + } + if json.Unmarshal(raw, &result) != nil { + return false + } + for _, target := range result.Result.Targets { + if target.Type == worker.targetType && target.URL == stub.URL+worker.path { + return true + } + } + return false + }, 5*time.Second, 50*time.Millisecond, "worker target was not created") + // Requests during worker startup are outside this settled-target test. + time.Sleep(500 * time.Millisecond) + raw := evaluateNetworkScript(t, ctx, cdp, sessionID, fmt.Sprintf(`new Promise(resolve => { const marker = %q; %s })`, worker.targetType, worker.request)) + var result struct { + Status int `json:"status"` + Body string `json:"body"` + Error string `json:"error"` + } + require.NoError(t, json.Unmarshal(raw, &result)) + require.Empty(t, result.Error) + require.Equal(t, http.StatusOK, result.Status) + body := fmt.Sprintf(`{"marker":%q}`, worker.targetType) + require.JSONEq(t, body, result.Body) + url := stub.URL + "/post?marker=" + worker.targetType + requestEvent := waitForNetworkURL(t, ec, EventNetworkRequest, url) + var request oapi.BrowserNetworkRequestEventData + require.NoError(t, json.Unmarshal(requestEvent.Data, &request)) + require.Equal(t, worker.targetType, string(request.TargetType)) + require.True(t, request.TargetType.Valid()) + require.NotNil(t, request.PostData) + require.JSONEq(t, body, *request.PostData) + responseEvent := waitForNetworkURL(t, ec, EventNetworkResponse, url) + var response oapi.BrowserNetworkResponseEventData + require.NoError(t, json.Unmarshal(responseEvent.Data, &response)) + require.Equal(t, request.SessionId, response.SessionId) + require.Equal(t, request.RequestId, response.RequestId) + require.NotNil(t, response.Body) + require.JSONEq(t, body, *response.Body) + }) + } +} + +func evaluateNetworkScript(t *testing.T, ctx context.Context, cdp *cdpConn, sessionID, expression string) json.RawMessage { + t.Helper() + raw := cdp.call(t, ctx, sessionID, "Runtime.evaluate", map[string]any{ + "expression": expression, "awaitPromise": true, "returnByValue": true, + }) + var result struct { + Error json.RawMessage `json:"error"` + Result struct { + ExceptionDetails json.RawMessage `json:"exceptionDetails"` + Result struct { + Value json.RawMessage `json:"value"` + } `json:"result"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(raw.raw, &result)) + require.Empty(t, result.Error) + require.Empty(t, result.Result.ExceptionDetails) + return result.Result.Result.Value +} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index af79a3921..211097ef1 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -2510,10 +2510,12 @@ func (e BrowserSystemOomKillEventDataConstraint) Valid() bool { // Defines values for BrowserTargetType. const ( BrowserTargetTypeBackgroundPage BrowserTargetType = "background_page" + BrowserTargetTypeIframe BrowserTargetType = "iframe" BrowserTargetTypeOther BrowserTargetType = "other" BrowserTargetTypePage BrowserTargetType = "page" BrowserTargetTypeServiceWorker BrowserTargetType = "service_worker" BrowserTargetTypeSharedWorker BrowserTargetType = "shared_worker" + BrowserTargetTypeWorker BrowserTargetType = "worker" ) // Valid indicates whether the value is a known member of the BrowserTargetType enum. @@ -2521,6 +2523,8 @@ func (e BrowserTargetType) Valid() bool { switch e { case BrowserTargetTypeBackgroundPage: return true + case BrowserTargetTypeIframe: + return true case BrowserTargetTypeOther: return true case BrowserTargetTypePage: @@ -2529,6 +2533,8 @@ func (e BrowserTargetType) Valid() bool { return true case BrowserTargetTypeSharedWorker: return true + case BrowserTargetTypeWorker: + return true default: return false } @@ -5007,7 +5013,7 @@ type BrowserMonitorInitFailedEventType string // BrowserMonitorInitFailedEventData defines model for BrowserMonitorInitFailedEventData. type BrowserMonitorInitFailedEventData struct { - // Step The CDP method or initialization step that failed (e.g. Target.setAutoAttach). + // Step The CDP method or initialization step that failed (e.g. browsersurface.Start). Step string `json:"step"` } @@ -24314,601 +24320,601 @@ func (sh *strictHandler) GetWebMCPTools(w http.ResponseWriter, r *http.Request) // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+z9i3IbOZIwCr8Kfv5fREvnK9F29/TsrBwTcdSSva1ty9ax1NPz7XgOCVYlSaxQQA2A", - "EkVPdMQ+xD7hPskJJIC6kCiyqIu7bTOioy2SQOKWyEzk9Z+DVOaFFCCMHhz/c6BAF1JowA8/0Ow9/KME", - "bV4pJZX9KpXCgDD2T1oUnKXUMCme/aeWwn6n0znk1P71vxRMB8eD//+zGv4z96t+5qD9+uuvySADnSpW", - "WCCDYzsg8SMOfk0Gp1JMOUs/1ehhODv0uTCgBOWfaOgwHLkCdQuK+IbJ4K00r2Upsk80j7fSEBxvYH/z", - "zR0qmHR+KvOiNKBOUts8HJSdSZYx+xXll0oWoAyzCDSlXMPqCCdkYkEROSWpB0cowtPESAJ3kJYGiLbA", - "hWGU8+VwkAyKBtx/DnwH+2cb+juVgYKMcKaNHWId8pC8wj+YFEQbWWgiBTFzIFOmtCFgd8YOyAzkets+", - "tjfEnlfOxLnr+SIZmGUBg+MBVYoucUMV/KNkCrLB8d+qNfy9aicn/wkO+35QcqFBnRTslHL+6tYf+OpO", - "ppRzYubUkEyxW9C4jonrm5A5FRmHjEyW+P0NKAH8iOV0BvqIFoxoxLXj6hyOLG4pycOuJeSS0+VCsdnc", - "kFRm4PeQSZEQnSoAoefSaEJFRlLOiomkKiM0TUHrIbFT1256ORV0BjiNv1wQJrQBmhHImSHjglMzlSof", - "0YKN7IrGww9i7cRTamAm1dL+DaLM7Q766TZ2UBvFxMzuYEbN1lsQ2eUz281ivixVCj0BYM8r1+PXZGBU", - "Kex0s/Uju1YlEDbFjbAzJFMGPCMLqknVi2QlWHzV7CMQznJmtMVHv8KJlBwoopqJ4D9OhRiWgzY0LwgT", - "5GfB7kjOUiU1pFJkCM1uODWD4wET5o9/qMEzYWAGSHncN/Vuh+OJbPcKZhsdACb1uVV72hPfz/wB7kBa", - "Li0K2ytR0CWXNCNTqci4QisCFq5epyYWtde30h0o0eUkZ8aei5Fk7IlIfS9OZQbjhKS0KCAj1JA/vfjX", - "b8lkaUATzm7ADqqWRJo5KNvKlJY8uY0bkpPQ8ZZyixmapKWxBImSdE4VTS11nFh6TNUSrxmITNtTHQ+H", - "w79VOPP38ZCcTLQ9e7vm5ph2ocgiGkjUuCal+3GUR5DpF8r5UcplekNCO0tTLfI62qLsTHLGOWuglh9D", - "lPnEIVI1gxGLXIkLyw0gI0qWBr7R9XwTImhu99SRNUes8DtNmNHVFA5gOBuS8TW9gauKJo0TMn4VPavD", - "6D4ox8uiM7Ro5X8nLLNMacpAkamSeQdhDa1zlmUcFlRBdFBtqCkj+/7j9fUlCYIYca2Q/g4jF3Xl7jUW", - "srLz1XjtU99wHe1dvDI0vVmf4unZJXlfCktohtjkWtEUiIJCgUVDJma4N/9Ob+kV9nPMStu29prYH21v", - "ZNLCXc0heW3JoSalBmJHEDS3gFIp7M/IyBVFrDZzKogW9AZGKdVIL3MUKyzc07mSOZAzuL2WkmtyqaSR", - "qeRkwRQQR/riPIbz18oi2HbBAlczxcYJsaircqmNEyJa4sMqqeFlLt66u7E2yH+AkkcTqiEjriFxt4gs", - "mJkzJ6ZwJqJ4kAympUC+/ZbmEXLWOInQEC9TQizByAuz9FQJKQgVUixzWeqqsY6isJ1Nj9XYZpG1uNbx", - "1bjfzrM47rnPjesYnV2p+Hr3n9+/sUu2aw/UzEObMh67qCs3rLXNjXm64VpbkrTPO3bV2iLiCkdbQ8LC", - "cULC6QQ4HhROHy+VwRvoqCHVS5GSlJYa4vSuoCo8Ijh/Nx0c/62XpFNThF//vsZ9EWRrMohJOBX8Vg/X", - "NrNx5TYSosKkc3o6p5yDmMH5WWxv6D8sC60JtJ5T5cRfx/gdxZYCyC3TbMLB8lgHcEhOBEECfjRTLENG", - "nc4pSakgCoxaItYSSqYK9JwYqm9IYR8uxtirkxAtEfC4gjhi2ZjkdEkmQKjWMmUo2yGYvOSGFRzI2ALC", - "lsj/tRUICiWzMgWFnZGaqlsgzBBqJThNKCmsKK0AJZzFHIQTsf0XTJOCKlNhtsXyalIECqYtHyHnhmQS", - "NBHSECYy+4oEtyxpCZ0lA1I1OlqU4IBiBBLONYTqOKn3oEtuOh8w1UGE/a4GVEBRLKDEklZmkV6WJpW5", - "fcB5gUyKFPAYGuf4C2XGXwym3cEnLaZB3TIVTiyxjCMDN4R7n8hbUJa211Opxh37aY605Lcw0oYqA9kY", - "5bKV3xz4MXELngBu8i3LSsoJtlA4Dfd0sict07RUNWagBB9IpcWseol9H0duPg99HG040P1bqfOtFJCh", - "JgcOIR76dvJHtwOpXD2ve7yoqovQelZ1LXHDM8v3CJvVH/uubY9fk0GTut4Tf8/P7vXsqYT9ejfsw40i", - "pTQSHySrhCohqSUmtoV7kPmL75mGxbcJmAVAm6bWD6eu98H1ykCB2EfY2hjHzMbH2AA5h5wgS8lWlpNy", - "oIrQKerjVqY6JK8tRZU3IAKp1o7A5kCF5UnVa8U1spfUvwReIrrYD/qIiuxoLnlm2Znr2Z4Dzk/QWzbD", - "y00XdDkk4yllvFQQ1hD4ozbSvbndlAWBu4KzlJn6IPwqPAAUdO/mtNQWuD0xvzxNFnPGYWUyCnLKBGRD", - "MrY0QpZmZQbf6Lr1EYdb4GRhuc+kzGZg7HTspd4Gm06oyKTAM3JH4zAOROblBVmacCIZ0WyGJ795xRUG", - "ojbgjlrmTSaAAiFOZcFwjvacMqZzpq2U7sVJPIVS2KsO2fCDCDhUMbOVY0tQikAVKpcLQpUshd1fK81o", - "ZgClKG0Y50SBJVeEWnmHZQ5ZEuSB+Kf2m4byFOIXcyJKoSTiODVkMacGkH+2dtSuZFZSldnrpss0BXCz", - "HyQVVXbrsBTdIYS9c+5kB8mgOod1Gp0M7o4sjKNbqoR7Hv5tUFGUqwC1+uZ1Bb766roap/rqpB7w12Sw", - "gIndq9Fc6oik9KPUlUhXBE3qKilCiSgq7QfgBTXziH6DmnlP4OT/KZGQuYci3KW8tLu89c3UIv0NRUSL", - "oO+gl0BouPFbBMwgWOLRV5R3g3D5G8hWq+vYy1Vb5aqmkP2E+uhNB7S7IBWwLypGtZ8NX6wI5e4hvpws", - "fq2/7a491bfYSUszl4oZapjthF0Ny5mYvSSZRMZg36q3EJHTZrSwA9DOR5t/Gi7mUgPJgDMU0SwrcqbH", - "1FI4qgCHsZyFCgOBo6yJamg2HHUZEixPPtIFpGzKUmdidPY0O2MvnHg19qv379+9H52eXF6f/ngy+vnt", - "1bs3fzn54c2r8WGl4ZfCMTitd9IqX6/t9diDGR8HuUGBKZVAylhqamVJLTka/Fpy2Fpr4Rd1oAHIuN4M", - "O+uG9OT7ZSzDXXX9mwrB1KKYFawagtSKjOSaWJkiBc5XpJa27Ehylh3hmCtygFv27oKApUhOY9OpfwrC", - "4RHVVlSzguaaRgqVQHbOqLK2eNaBpBuZeU9JYTEH5Vm654WW0jtR6DGEhY3wH1teeJiAYOl2kxyua5b9", - "Cm6YyIiCQtpTCPZzh7xDcqnkLcuaFxoVSs4w5QiGyilnH+3ZC+MFWQ3mmIAwoArFNJBbqhgVRtu9VODv", - "O0kl57TQEDoCU+QWlLakbVKmN2DIwe235Bm5/e4wIWN8VY2oyEb2VTV27029+tiqSL52onYpOMMnkF2m", - "m3K1VqrJGO2G4/aVmQeRx55TOJ3bb9sfv7PnWiphZX17bDMAA9ogF2tOdJAMcIzoBYsc4ZW7Dv2EPIX+", - "B4Vx4h3VN7+ZSNec9l6m6ynTedL3yRRl8ZPaXbhzJoFuya4td+haiY0a/BXc1R51k1rF3VAau3e1mPGI", - "xgff2p4HkZyKpVMz44M6lUKXuSUQeakNcmCq7TeoYPYTdPNra9Utiy0oU8GOMlkSqhS7RT1EZmniL3YR", - "XuGTNMwaqVQKODWgG/r4YEuphL+4EJx4vXrLojJTsiy0151vsuecBYnOaxCks98e261urTSnfjXgNUnM", - "6Mo6gB4XDI0HdesJkJxp7Rw4qn0g1EpnKbJCBVOpIGJhSUvljGRBDDYd+vzfjZj/SeQdf+IdpqUHCzxM", - "kwmgXshLJRfuFJ3V217WlkkG1UwpVWpJhHQwf37/xgvoDSOhLid47fXh08hRa9N+DGFqO0Xci0a/d9Eo", - "K05KI6eM84vog/OXOUvn7qTk1LuWUd+D2P9ZZDqlQgqWUu5tz46aZnBrpOT6qPA+M//3t9nzF/8K//Jd", - "e9EpVXauNMvs9HvO9lqx2QzUqcxzKrJ7MNorKphB7BkHmEPjgI4JVbMyd7y1XhsTRWmOY82Z2LDYhBRM", - "COfQNzem0MfPns2YmZeTYSrzZ87BKPgXPVuD82zC5eRZAAaT7/7lRfYi++O//un77Ns/Zt/96/fZv/xp", - "kv1p+if67bcTir7bz7zb7ijAGNpvh+SVU0v4tTmKwTRJ3R6SOXXWF4PmBMuGFGQ0RXEPUoa3gwnC2aSa", - "ZaHk3fKZxT4rIj1Ls2JUb92S5jzGkfxBj1CuHKWyjEnizs0FPbpccyeGenOBn3HAv2u8wZ40iCxgIVIJ", - "1O8HZ8kWV2jIkR5glDld1wN+o8m/X717e/T+8pSwzFsW6ulYeWkC5D8l7p+jLJ79N72pa12HZRdo23DK", - "GO+AASTlDDm7/Z+QwvP1HvJwKoWAtNM/8jywU7eNp2eXBE+Q1P1aC3KyTEakSJr8NitGvkPwV8iKUcZ0", - "+HJIrhfSL0KjO3lw4UMXkrAN9niMpZwUvUyQ/jPt1prTuzcgZpbbvfj2TxGK4JBngzAxoekNiIwImbU8", - "eTyfdI8aZwWyeO/vdDeKoH/epgGxQdOLjJxyhlZFI8mLb/9Ue+Hql4QSLsUMVO2siyI0UWAJTQ2j32bk", - "YOYyaz6EVmlUlKrmnuLv5ra1wjHWnbd+CZvqNvTYSmgqGxN0oHaXeYx3NtxTM4dcA7/tvLL2sQtad6E1", - "OvG535sn3cRkPGM3Nh5Iy8XZI6S3u/ouesP59TqWFcnJn1EDczeJT1lRCVIiBX4mF2hDfRR25yEP0xbo", - "LUyvq9Oe9e3C+sLsMPDqzmykX55K+qa/DWHZ88XPjS9m/maOZuUG5Frnh6FfzRIrq8hvysjidCfKzr4c", - "FtE+xH58gksNj8seLMS+XMG13TODXZjBnrh+bsR1A3myF+CLpkq9qNAVmFMXU66v2MfHpUe6DbsnZVrr", - "tadRexr1JdOoObDZPKJQC7eAuAYWO87OL7u0Hd2UbuVCfSE0LxksWBazrVTbhr9v2bUFE5lcRJftt4+4", - "JmuS8ZZI3EoyrEfoS49/wR4/yFJk+rHpcRN2f3rc7rWnx3t6/DXSY3cL+lFjDtNuCHekkBrvsYXiUqmQ", - "VEqVMUEN6HuR+OYd/WJIvJFF5y4u77mLHWzDQ/20TKOChc4g99Hsu2lfYfeIYt9NA6GHvBSQkYOxM8WP", - "EzLOmWC55RP4gd7VH6Yl525b0f3Y64t8DJRLqVD5uGQwZQKVSR0W8wfzR88JO90AAwqGLEYVoWmRPBli", - "hyzG/wKTK4m+A0iqjh3rIzPQplSgk+BQjOlIMka5nLm8I0zMEswXQDRwT9zQd7zOijQkp1JM2Sy4poc7", - "4cKd5kDO3l088zk0iFF0OmVpPVfOJoqi+5IuocpRVflHT2BO+bTyTw9bPvwg3glo+HB17YkPGXdhMA32", - "4Ww8oZU2CmgeFIQaw8iyhKSSctBp4JnetQpj6N3ITBOObjmCL2vG4w7c/hh8V5AsA4ccQ+zT5nYlDYZV", - "8ceK19bu/6nMXAoLbJ5yqjWb+qRolmfaVjcABSmLIXllx9WYR8TuHJqx0RVllcsMq1mNwlzHLwPHNZs7", - "ZArjEj91FquVC7J3OO12OEURA7fqE7iZRo4lmmEo3Lm2D2k91eA5aumQ7Zsjj8MrNHY3azwkr2g6D/5T", - "6MLGkHZw56ZVXx9aFEreeiHR+bu5UY5r+y5+WfKMoKcUJRpSBYb8z3/9N7GLzVzWN4tLngcbuDMJ+fn9", - "G50QBVNQCpROfHIVnRADeYG+oJ5yFtTM7XIUnZE0PNmQYAdfJT8XO6R3xeI0dWumhKPckFiSae+x/cN5", - "oKZAppzOEgxLEWWOzpnowofoDjjGXGqfA6ixnS7DYk6LwqLC8T/XTfK97ewRX6ekyzCyFehWu3KyotTs", - "DXHVApF06gz6gtygTkw6pdUdgHe9jZPB2buL4VSmZQ9wZzJ/bVuuA9CpkpyfCyP/wmBxPn2LccS9IF5F", - "u0aGAPOacTi3oob9o998r1Z7tQHj9wGxFJ3NEIO3wcVep61OMbAZ04V9odk2XvzqBflstd8m4D/B8j6w", - "Q7dNoC9kqeE+wOuOm8BfyzKd3wd83TEGHvLSkkts9FrJfOdVvOoEEBuO5YB3tyLoPUc5X+0XBS40KHMN", - "d33nfl51iIHTS2HmYAWLSybS+b85eb0n6Kto583DuLt933FavTcPdE2L+45Sd20PcUlnMJxY6eZavlay", - "D/rYLj80ekQAel+vOuViP6inq902gBa02B2w7xQD2481IrB1voggXM7JOpXeGT4J+8H8Mdo3MkgIYu0H", - "9q1vvQHQtfyRaSPV8pUw9u2xC9h238gghWLCXMvLs9f9AF/69tk0AswlMesH6D2sS0EIxAoXMHnDppAu", - "Uw5OM9IL5FWkZ2wAQ5VxGJxS3RM/r9qdomBl8UbSrBfndiCrDh3gdp9ks08b6DVVMzBDmhp2axEDP24H", - "69qdtHpFAeP13A3qad0lDlIBNRB6Oae83rAjfTcMsuPEG32iQK1oIfU9p34W6xwdRhYggimmL/R3jT5N", - "oL9Wyo6ly0AaNHy/JgMpYCedZg8h7NfkXsBi4uKOoOKiya5ANklN91xbXJy8J7CozL4jrO6XxY6A+kmy", - "OwLdLgfeG2CnwHdviHHhrj+4bS/InSCtvZ13m8fWd3J/cJsE1d2gbBRM7wcqIoruBmi7yLgbvJiseD8I", - "3WLhbvDWhbjd+selyd1gbJDMdgXUJT3tDici1+2IhKtPmB3nsEUU7g9tmwC4K6QOoW9nMB0i2P3gdMta", - "u8LbKrztCrBbXusLZ6v6eXdQ90fO7Zrg+8DqUln3h7VB8f/r32P2oIvKl2ObIfv07DIYT73t/W7pTb7a", - "GWw1mBDh3rCYakIFoTMQsfpAmJXh5arR9ezdBZpHgl16IuXNDUCB9m77g3PfqgP8fz5vjKYwWbZmGaDn", - "Ue1Rhs4BKObq494x5p063E7NcUTb2K3d3KDF7dZ7dyjae6hst6gut6kcN2oKu+wKTZvIRvNGTEnYqefr", - "0NJtVpGt6rY2q6ja6qB1TVOHIiau+YioV1oawU1qoy61R1RrEVcJbNZGbHnxx1/qnZbEVTtgt8Wt29CX", - "9AtEbtIzdIHo8ssRBO58cTvv2+IyqaSlNjIHRa7OfmpWGkvIZVkUYADUYfAdrD0eI147zjmGafKXi2Ff", - "jwvvkPgoThf16vdOF1ucLnCrnjJha+Q87pHxvnZYjXhjeN/U7mJi/b1kmY66yTb8Ye1Rj1sgx8EXLOYc", - "4tw2as9aZMNP7S/768bzOKt8pPoTiNqvCrI6peuWq08uKVN1rimW55AxaoBjGZUUsnX/Yr+TuBHO1+03", - "ICArG7SnIRtpSI0aT0tGYqeyOyWpZ7tOSRo+7Xti8gglCRtUu09Vwhy0pjPYnsvIvb6wsSYKOF1CFoox", - "rY/rswVmTLnv4sXNFFAdK/D1y3y5ChPrQAzJ2G36yEVtHzcjOfBlhffWfSk1DMm4LBxFG6VzKmaYOhnf", - "bqzM0YnV5UDEDMne7T/4JTsMMi6D4UJUgShuNOcTqCDgNZ1RJrSLQRGwIGHc5hQwIfT4uPoNXamJVGFf", - "SVHmhUsh7dbqU21U6Qz8gkNdxZBfo5XygByYZWFfm3wZikXqeWnsEg5Xkpc1tnKQDFZ3qvkVzgnruK3M", - "KJ4cetUJeBNe6bIIWfTiTtr+Ai7AFS1cUJXVcnC4apPShOQ73vcZsqRV6uMfJZSuPsa05LjrK17SBRUs", - "vbEb7wKmmEgV5N6P22eFQHfu4KL6P//136QU9fwxS2Zw6XaKCqcMmDJusGrixCXEpBkmzvS45qdt7x8x", - "0i7PUTEjDeVDcl05hnN71aTgy5e+kE4zIiUUFnH3sr07Q3LCF3RZ1aJBIoZlOoMDvF0BYealy97ZbFAo", - "yGhVxxK5ZEIWmFnP+8NXZI9q8hGU7ArwWPcl34QW9UlXF66JDT7fZkaEjBHt5skz1XC593kN0XPdodkw", - "zYphmNHIH9t4NWqhWTrNngPGpTFEjeA+74g8imnjLjf4LbGSTZq/SporarmFYUfMUveOl6zUJlsiJBvt", - "9jGROyVVcpneRkJm0Ccj3Nm7i7WscM3YNINakn2ywC85b0ety4zx3m2Y9FAMcjRn0whBZ64glwaI67B1", - "uE+XqOnryGrSy8HgQYwhrkPvwSm6Ou5Zx5517FnHU7OODtPXnpfch5coO/+uNAk/uvwIXi9jm7am6w7C", - "8ZA28maynGB69DVtDY7XGbef1bn0H2+0u/WR/krkdKqh19ISfJNi5TJ/2y3G7DaF5foU/s+nm8LXIy9s", - "cox8mKiwCrmPlLDeZy8g7AWEvYDwmInoeQ/Fv22lMd1HfSB4UYfkdRW/vkPBgg6xZM1rZy+RfM2v2xZ2", - "bmFdwS0tXrLH/hxSwcyphnvUfAn5e4g2qIKeYioFd17B0w/V0hoMUrQqf41PvVKUBp0Q1YQZzGfjqxeF", - "Ij7B7N6yz2R2XcKAGiT497tb/6cs/DfOFbVnsRy7Dxcsh9OGJX+FSsvCn+3F+cUrEmzFWMnE5adgBvKk", - "dkU4P3l7QhTMmDZq2dKRN5MjvWz2/kYTXU7sNH3RE2dW4K7YeZWjoxq7kUjpNzoG74CG9pBBMqBlxuQg", - "GdyyDOy/tCi4NyEhf0FVfS4zPJe85IZZylyr83uelvdpdM4MccRGWkmcGZ9QUrtEZiFzE/GOmPrzwXmY", - "0pLb7TKyTOe4laXuu2lboq3uLcPGnF23iLDxLnsJdp8x8utSOUX9xL/eZOPbY0sfSKTWfPV70alIrz2p", - "2pOqL7rajKKz0S4vYF+bHyVZ78V4zxcwDm2F4e1D21YdQ58byBsJ43YbPWc5jLyQ7VF45dXEtGEiNcRE", - "HwWYUG9az8rN00U0jK3EPE7IGEVm+0dDRh4fDsmVewBonzqvewUPzbWaDHBeOzghr76RaqduqhRdVvtn", - "7z6uR49yqm8i+W+Z8T7F9vXEuVxYkmO3qu5KDl78OZXFMiHf/pkzcZOQF3/8cy5v4bDr7PAZW9VD3jVF", - "bvuhvJ4kd/WlfEzG1RvUHmN4hLq/ZeHq4tUP0fHDk+N2ixDrQWgddQFRNtBbTmRu/w2NyQ0s8TBOuLFn", - "cWoUT8gf/nwBhibkT3++mrOp6TyTzzGddMSo8xcGC3QDvGvkjraU5/TqihTsDrjubTJZbgC/fCj4Ln1R", - "427sIIPFEqE8kggWQO8kgdWd9gLYTpV5SyNHCgqgsXTzc/CqqZAU2t66GQhLiV0i1RvAWGKgpnXPG1Ee", - "QcTbyrPBoo6Y1R6rni26FNknnI8PfVLaqnI4TmpvWPlSZMuHselAAjq49E+wXGHSN7A8kwth2fINLH8u", - "7B+KLn7yXyOXtiziUfgz06MbWBY023zN7H1iVcJ2UeagWEpcz64bxvRIL7WVi29g2ecWo2O+64IDrl+j", - "BnQuvZZ2DfBPsJxIqjISmlhZgMMUhQEfjPvdn0WZFzQ77G/W6ojO/52ILYLmkMU32mJYMxu2u5AFnUFb", - "x78sKn0uC+HM40pavKYT+8+JUnIR0PP191b+/8nOu+nGT2gtwkTeAS+JmUsNrWT1k6V7Z4xc/uu2f/1n", - "XuujXtYmNlPDbTu+lJOcGeNryxNXgNdo4FM0LfZ4Ij6ugBXPUfZIIlYNfCchq9ltL2bt5FRSGuNI6K5c", - "Dff8B9d9nam5H9AkWAlFYW0HYytmWPJhabIr1ZJlHL9B6mz/mND0Bmu1uLihxyjUkvjVbiPKvpUjzplc", - "iCF5K8XRR1DS8j9KxmjDupC3kI1JDlS4G2tf+o6FecWOmXeKgJylN9slTww3dtKc20+MDOQY1kYOvvWD", - "4bvKfX24lzm/GH0mcENj3po/SsU+SmEo906RBJsmLgQbUfOXOQAf937eu6Fij3xLKNLHG+hhcnRN5Tsk", - "aWywIkvjNC8dw0dSYz+/Bw60+YW/y6vregSSM5U+3cHKHccyVcgCCzu5UkFCnqOI0Xs3t0mojSxNvxMZ", - "tZD2g7o3Bly6/h3H7391kmitDKifFwfucN05F49T/eszFUmpmNnZUD4K6LcRR+v2DXQ9erEbvhrGo+7n", - "l5YpMG5qP6T/c/QfpOBUQOLEuZkC0LuNs+wzzl8fNs6CaRMfBlM5LJgGoqTxOehWR1i/Hnst7uaUz4/0", - "yKiB7/TIaHbbPzL2xvS9wrMzQWt1Uzo4NTZYEdTQPxBzFlupDD+9Eln1t5XQHOPGj49ooOwvoQUL+ZQp", - "bQjOg6BI8+iiWyNp5+9EdFM0Y6Xe8h5xjTq3qX+UmBts04vk0Yby/HlExYxH0OC9/33D2d9DdPhKZcYN", - "m/hYwuS1FfC6hnlqCbPn4A8UOy3IEYLcrkHC/K7QnIZeYapot+zM8RMXcU/XxNsdrscD5N7O204cR2kU", - "ySbcrpsJ9Gt3PAPfh3rsBQumLVtwbIPlVC39KTUtIjTsk79MQdR5fJH7gXSsh/QdQ5weEnm/EiUPlMy7", - "80v3ktA3dd9L6r9zc8BjiHCPoE7fK86/VsX5E6nJf7dKcTIBu+meaOJtQs7wlM6eGwoI7L0+916f99YX", - "biw390ChZK20Ri9ZJNJrL4LslYVfY5Dgem2av0dTLhWcppiYdwQislPv6wZEUTEDAiLz6YI6tVUNoJgT", - "ug9YbLgFsPP5tYcanetV+LkxR8zdz6rTDJuxfYSOiddjNCe88yhfgx9dZD8e24muOak+DCtaZPWhnKoC", - "2o9FNZrvedOeN32VvKkupfb3r9bN2G3CE3gW70gUt1csfiCBjBen60Usu7ruCeeecH7JhNNnHBq5dET3", - "Vk+tpz1a11C18h7hKjZmPno6LVVHDcv4q8VlIB3pAiD6aPEZSr3LI8F2aAxzahJS2C+xkE+njJ5SDqMp", - "TY1UG0bAZsGAVNiJk4MP5fPn38EL8lFKTDdw+EVbpr88PdhOTLOzKv+jcc3WCDuyzZW+e76555t7vvml", - "8s12cecY4yyU0/BPuf2iMyYYf3aFsMqicOykKxDYhfn38IWpaKHPDFC5jzm73QZlooWfAafLaPW+M/sL", - "mYBZAIgAO1kr1/cFqeI6pJ6rBSvAiTo7STpPzMDvLBEwVMTcPCNGYd+2PdhL4lSItwFbNOEwNTvMAevY", - "Y9dIsdgaNef1hKqZVMnvC+pLEkLYzN9afkkGyw27u2YZ77u3ZbHD+D139jZM5lH29REEt7rU/ZNIbTX4", - "HUW2Zse9vLaX175obyFfojHC1UOtyMDY0UsGS8diLnj7qSx6s/mvWzCsScqXo2+nRW+XQ0kMLVYj9g0t", - "Dr+eUMDNnLGVIymawOZzzPjvEzkNkgHmcRokgzqN0yAZWJTrmQm96V67tj3OxW+y4mNLaw/bz2S7LIsa", - "JAMrV9sLiElB7J5hOa4EC+A4/FtQle2ycRtQa8098rPZrWZmg5BXPyQ2CJ8xr0H4gEkNem7bJZ3BD/br", - "a/layUdy9rdAh5MG1C1yaaT9Xhzdi6Nfl7/C2iX4irPt2704dR4JV6kCEHouH5E0paug+9CnSKc9kdqJ", - "SLkNHE1gKUU2uvVy6OZ8kb4TgTsDWMis0uCE/p1pXzkrOkt8WpTGAkhSENfmngI2joKW2c2DYBJGZ8F1", - "ht7dRugoHdocAZs8ZBV3m+HfBQfUBwyx3DzE8oFD7Lng51bI0G/G7uqRmgy/djAi2pHc3jk3BLEsCLSl", - "aQfj/yxgNk7IuBAzl2liAZPicXJE2RfESJdqSmNa8hhhsxzZ0BsQddi4779Wl81SvC5q1yFPrDGtqFAh", - "C8Ny9hFGU6m6HF3C3EGkMrOYMKW3UkHmrUDyFhTR7CN0TfAfJeXMxAiAzNHuZ9HWNwppNp4/dznouNR6", - "6U/yCzKvPVQyErR4GrnIA95FKqq67GWi/cNtz7KiLMvfkU6GFRrEeFY+Nzl/HB61hVX4SXztr08uNTwi", - "ZbXgetFT13BPRfdU9CtUfyH2f+WU50cqMg7/Tm/pFa7xjFEuZ49HiuZR+H1oU1fPPbHaqeBRmkKxReuV", - "4c46rMXmbkIZ0znb5CG5J4RfCCGMX7UOR1uZF2a0e1QgCAMKHTiNJJQ4OB717h0p+OVQ6iTc1O0k+y29", - "ZTNqHlFeFB5iH7Jct90T4r3U+EW/vRXNIbrIdwX9RwkEGzQoywbK8JJQwqWYgfIPauZe0Bbz7A2rYTyI", - "kIfL2RFcOAWl7AWRnKXL+ygV3nsQlw7CulIhNCBujCdLzVevRYHF4S3a73ZaUtx1199xnvCpH/dpSECf", - "pYOhosJlLbl/Ju4KRIerqGdRdukKqMZ//FEGXKCcgwrV4DgTWJbJTgiTcyvgkj5SeaZS8RGuIWIjucLv", - "qxrVoA0Tbt4/v38TZof8CwtUT2SJecQt3beTc8ijq+y8tlMH8jTO5btvH+35FmSBa/kj00aq5Sth1PLx", - "JYM2/F3khNWee6lhLzV80TUOLJpHF+nvAcEWreWEu4I8oHfh0A3X7ItWajX2eDuBvFRMmGt5mU0fjygW", - "HubZ6z6EsNl6T/z2xO+LDntiuuB0OZoDzUCNplIaUN2yOSWuIc7XNSYLUPZ4RAYb5G7XdmQgLzg1sP0V", - "QAP40KUKAueszlTlf9r1FRCKwo0ymSJKj2RpOBOwaT6hLfFtcUKQTyDLeoxk6GwG2ajIppvGcK3IAU1T", - "S9snHA7J5dlrHKoy93aN5c9wlz32p/kUe8wtP0lpscXPCR0f7bCcssxuLRKi0LfTnYmqGROjiTRG5pHk", - "0Pg9ca0I/pfOdyit4cFjAMoa8DcwNQ8GreJ+p+/R1fShwI0sIvRXFg8AHJdlak4ZVz3TGYwwp6rug4y+", - "ELmYreBhBw4UtADV6cF7aX9t+O7uuGAHvMOt1sGuHGp3BY3Ki1Gq9Qg3SLOPW+4I+rqigzD76Pam8AoQ", - "71jnLlKxxcUOT2s0oenNTMlSbHDiq9uQmaLFnKXakXkEsUG7End0vsSDRQ7hfJy/7PpIigptTziX2f2q", - "m2bTaw/jwoJY19b8KBe4zpo1eEnoYPwe/zzRP1ANf/yD818N311h9Nhj6Gjuq/14j1qixxPsvdaph1Af", - "Wu4F+r1A/yUL9GwmpIJRStP5Fq7ibgSZLAvqyCMqedN5J/+wzUFtsq9UIrJr+ruytLjlxvUsuJJ7pRRn", - "whI6yIjrsHc+vwLzC0zesCmky5TDlXlUu7eOQO9D/eP99rxgzwu+Queh2GX4YrKxaFzNPaTu9S1Zl7ur", - "BgSHIdS9UMnBeKrkRxBO3Ha1Np9OzA6L7EGMDVXGBXelVD9iJJBuA+5Fgle77Knvnvp+4bXT1XIkzHyE", - "bkfra32N3kia5gX3SV5A3dLO9KMPDYa1t64ztsjNpSsYFslaIWaPFGBE7zrVdhf0juVl7j21avVdM9Q7", - "Ymqld126ujbASme3BV4H42yTsCjPvF8c6/7dcGVk8UbSjInZY7KpCmg/FtVovmdPe/b0NT4O6jvwlQda", - "WYr0NLJzE25furSXnPekaU+aii3S11dCnVYMhJFk9gtCSfA9ydBOyGoz4WeT2rJtzBwkg7Yls28OS1d6", - "KZ730//oJl/vGBWOBLuMoJ9XLlBLAUH03JyVAJFIeamO+BAanGCxmupnk1k2rGeQND78MgdxJhdipmhm", - "t08qNmOi+sP+fKqk1u/C95bW1R+MYqmJflzvWQpNp/Cz6puAde3t3qFGWHu6uw3VVffP5oz+s4CZRWEx", - "22mLGqlT1nljPNVXc4ts989zixYwKfruVDvFzDoqxXfI+R75vp8PLZybvO8tu6ZqBuYkNeyWGnCfHkXi", - "d6CGtAV5i8zf0Wcv9e+l/q9L6o9ehC+oboRdziZ3Gtfit/Ci6SzoXM158wvFU1Au9RMQ07QG24+Stjrs", - "yeiejH6VZLRxC/Y09POhoQqogfCtFAbuHpeYRuD3pKrRnnvyuievX3rApNQwkmKUgaHpfFsiE+GyZGni", - "e2b1dmcwKWczO8dAWx1I0P3TeG+4jF3pwO6WI+f0PeJMm16RWXhYrhOxnZDap1JM2WxTDKAbTIO67ZP4", - "JYyzAhvDEN0vnr982eleSsFuQWnKRwLMQqqbkYsGHTll5PZac66dJjNFhaVYFUDiARIH0O1saG3mkGvg", - "t6DxgvRJ5nYf20WTqz3B06ABdxcutn8c3Id79QkltNfJy2B4r3G7sxA+UUPousFhBzwh3STqBfpe0dzf", - "Ivpkz9E/v/wvdMJhNAFLXF0KOYtBSvJurP7BNnaGF9/WZQNAUBk5mAPNuOVUUvDlYXdShLTUm6+OgEXz", - "+mCPTVkW1MjQyeYMA5MAsFk/xCv5129qd7KBuAPnL0xkctHw3Dw7v+zybpyzLAOxM+1w3Tqj1KJB+35a", - "d77CuCOAzvLTKHO6g0NohH1E5S0Bi9ECB990KvaYXat+qR4+y7d6LCuBP5flPc/lc8gUlww6XJP92iuf", - "5A03xeHG6P7BLdi/K6zFT8TFtDSdv4XlE9zuSs4ElgDCXH85vas/TEvO3YH9VoHl7vaduefV02lKstgA", - "/YTMjq57aXMnaXMvCe4lwd9GdR69v194yr7IdetDht8VIAJVekzqKxtw+xHddo89rd3rpb9K2tW8Bh1Z", - "ugTw6PaEbgSbEAsJnYc/FQfdWyR/ZxZJWabzV7cgTNyXGn93HtOkmFMNn42voLEzx2h5ux24TJGFPy/k", - "LYS/T6lIobdXYTvr+rrKvmfS9c9mGzkTN54OY4EUd/lGE6rsp9LI0UTKm5yqm/BZlxMXHW2RV5SUN7+p", - "3PFDayMLvK+e+Nu2ObNH5pP7JIMbWC6kavw1agLpc2jreSDi+ex4PBfEZ3NWLmMFlrExDBG81+40FAnr", - "aTc9I1tE9QmfUZCAyqnFsUrp4Uhr9Xet8ui1a1JoyeGVUlIh8VzfuJPmi9U2JmBbW/muFCktZ3ND6mJP", - "BO5SwK4hOuVVzgy6a2OJ6IUkGdOGidSgSKNlqVLQZMHMnGRsOgVlt8iKg0TPaQF6SN6XwrAchn78k8vz", - "U0t6MnLgvxm6GVmCpA+tlJSVFiZexwQrQSVWbNUJSkDa0PRmZBRNoYZdTft6ruRCkINqbdUvTdAOJmcC", - "EpJKXuYi8UsZlYpHxnnNgGeeP9rLmNIJhyB2up4oY1GUAqJiLzUwk2rZFKP8+qMnnPmXTR8l3CoW4KvI", - "Cjk4sZ5QsOeV64GpJu0Wmlg97mtlpQWv+7QnPbW748qJh14kK3F/MK0oZzkzehhVN5uIlQKnQuzBakPz", - "wj47fhbsjuQsVVJDKq041U9KD1VNVrZ8hCgX2fgVOcboADWpT7Da2E0iTfRMdlWvYs+gE/g1+edqPT81", - "i+zeCefVXa+eYySVoFInB7q16iG5dG4TaElyLyK3MH/buy6u3XpmIMex16VU9wVVii7dO8ner1jSCvs9", - "cQlZg9HWNbBzUU7RW9MjRC+83b0nvkIVhlEMcVw/+ggIu2g7OdaQEMoXdKnJhwFi0IfBg3ZxbfPiicLf", - "MAG//UbVBHJ9hj+/fxOMGX5mU8Y9AzVzBYv2HB9hYq20XIFQ9yWYlPMr2wvx1d6t9TDXMqfiSAHNkNI7", - "DuVZkW5pGAKSLGTJM+KT3qOq4bWsfnUs7uDQMbkEAUyZ0qbWxzQuKPVX1IGIsLLEG0Fgyu6QWbn55aA1", - "nUFC0AD1YfBz6Ilk6JhMpMw/DCzrb/x2wAQWYGQaDol9TPjGd/WDcFoKVFh8GLSMSF00s63ADKTx72vE", - "8Y2c9RZauJz5118lNXA5S6r9ZWIq608LqkRCwKTDw+FvwInDwvZ8eCsfjpcWfWQu3DqP3xcP3omVbmBV", - "nUK2hZGQKg+vkuVsTkoxZdyVYEVy67TQQzJGOjJGK6osXQ0i0hKZ3CXUhAltgGYvCeWc4DuFrHJMbUVl", - "oIpYHjUkV+C0oLqAFF9bSANLzonFiShheSLa/hoJ7+rxrJ/OcCupCxqD7SSvhUWdr1tH4YJpES9drbAJ", - "JDGXghn7gsPiuZzbXT0K3NMdz5CsqJ+dGi5xqcrc+6YOsSdQyBTdBBZzls4dq8aZyDQtVeWL0Eb87oqY", - "9pRXy2HiC9HLLm4ycfmnOw+0hdqdBDohVqCw8gQBms6bCQRi4wh6O9Lwj0hONymkcZoEviRMpAqots/6", - "xnZp+EcJIg0iWeKa2Xmh/t5NwMjCK4AbPaOb0IN63kNbHW6YVwnXVofofmxQLQfcXNMtkwNtUDiilhvo", - "xjp1WCj6hB1uGjHwhR432xkdXJFJdM+Ji6EKONxSy7ekM3AhKr90Tmi2gd2ZxpnYu4C/uauTBK1S3dZ7", - "NvurtZUoNA6rubHtJdcouIF9NUWB3Yycl0regqAWSXMwFKUDf3JLi83uont9iCLglTzVzV+XmiAuqV16", - "EEeWrLMpSz3lEPb6e0eoLt40xu1tUq9KRYVbHUecGxbzCXaiSljQEO1k42PP2EhlbboMfkOejTmtVk1c", - "h2R8A0oAH9GCjY/JT/iBnFyeExdqQA4snVG33qLovjyqk7uEmZMx3BkQFhHGx3Uqdz+f6rchGXOZUj4q", - "lExB6/Ex0UttICf+C6JKIeyJUS7FzGsl6+m2lItpVqByOszf/hQGGlja2hgoKukGVOlGtoiQsg0fAjdz", - "yGCplbsHz/w9eeZYxflZ67zDXVi5W3j4G27Mj8YUP2LhKd29CKPKtQvz4/X1pS9ZpUlOC3u6C6oydCM7", - "Yh5T7OwtaZOlIU6Vyz76LDV/cWpnNLQuC88/vJRHJqUhOV2SCRAqlmjYRhGpJfWsLeZcGFAUifYpZ+nN", - "1sdSiS8m2zRIEt6XkNwyWiOhy7nhigr0eh2xeiIPfSFF17R/J3W+kxpbP8KTfcLXUvfZPPKbSQOH1MhI", - "McDTqysSfiUFNfOgY8e1W/rKUdDqEClmEUXO9cUbYujMcSSvo1qBZg+sLApQKdWBa/3w8/X1u7cJOUnI", - "2flfOmSYqDD/F4bV9VBb5KifMB0DJ8QolucdysC7GGxYFFIZcndUuzC3gNu1YFEvl4U4imTLDYCX9we8", - "god3AztSUp+2O6GNz6QGCv4Ey60E7waWE0lV9jmQu7CePbHrRexuYPlpSF3rXB6Z0NlFrG3gT7D0duZK", - "+vzJ47HbW0eAXtkpJuQHmt7ogqb21R6nQvegpoHuoX5+TjMX/FM7xd3AMtQC1LqDOvWntj6yaBO1PX97", - "+fN1Qq5f/fX65P2rbpq7Kg7CAwjMVaok51dgDIdsK6nR2Jpo19wTnPBuolNTN6nCTbSRhSbpnIoZE7Pk", - "902e1ndjT6h6ESp36iOPGJ+GZnUc1iNTL0ueRnex6CfE87ujCtOp8R4vVJmGHdC2moG2SN9HLMHxlp3j", - "LR97PK+PuQf9dGNtE0dlbPNeM0F5mGxzC6fG+5iGFQRS02clMrZvraGWjzLUCi57DKmOzi/aT2h9hzeS", - "5jfsFqwYeupUlZ0UmbNbILcMFpVDlutQ+4Hbd/y05IF2f6PJLzB5f31a6XDewo08HJIffTsp+PIl2joD", - "QZ9KRapAW5bTGejehkSvZ30obY5tx54kd5JkixUjixXBX/4JKXHn0eyopAV1FDT3BV1iyUyLeOO1tYwb", - "yufVp3S3ZeBNdVHW7QNDctVS3ivwQ2nv7IilhvF6Bf33hLMC4y7sJUFXQK9ERee/KuRgXE9pvJOyvMeG", - "n1WBD/2pQx0sUXkx7kAiLilztqvoqUyWa8v9LUjEyrbsqUQPKlGjxScgFLEDenRa0YgK6iQXWalQXz3K", - "Y8khKOdHKZfpDQntKg1QHbTEBMkZ56xxEDvVVt9ElV660CdvtE6lUqALKTKMheqiijta5JpbsOHoLpyV", - "/axBPTpozrUP72oGdslg6bG3gktthuQaZUWjloFseoNApiQG6JTCMB6M+6OKHkMIDdNDcq2AGrQgMHFU", - "KDnDjFX2TqOvhnOKPwipnVjG0fNjBiNOl7I04Y1ySKgmpVDAGbIAN7KZg+hHwPwcH0q9unZ4T746yVfA", - "jiZPe0LytfGEttGvNh65IKRYDQgMTgreCvXC0KiW4iUaKcCXHmSVQbeyjoZfhk076Eqv7TvkZ7d9K84F", - "M68p41uJQaBtKXqF2qfFxL5JmWGUs49uvp/6pq1Mfn/Ptt4ze2CjKW7Z01+z2PHsdsm0gaIbJV1MJpGq", - "xkPvz2SgcKpgt1Svk/VBvhrMSWnkiTE0nffQyeIktq/2fWBwva5TlLe27paCI0B/JKbnlUYW7ua01Mb5", - "T/D6keN0SAbywugheSvJtFQuLdQqk14wzj0DJhixzXS427/FFY7t2v4eb73H1cF/ssvceVBPwjZbiG2X", - "WCoY1t+O/D2wDNTdA4vh4QKQBSggaKEpi8q9RZeYyHNacr5ENitVyCzQvpBNzhsZ8RGZ73t4sCi+sqoI", - "yaCrMsgrRwiCZjArq32Y0QL9fZx8f9oWwzERVYgdXXE3DBoVo2h6Y6F5UYVMFeh5UFIwTQrJhPlN6cye", - "xuxMYz4peXkIaQl3ta9SwG7f6vOfGHoDeMsqeA37Qvsq9dnfNdoQm+T2/anrdnUqCgtQTGYsbRTpCtqO", - "YPO99U4x/W5gDeeRLuHKIvZ3cOsd3HgEj3wFY6ez2w0sRMSDwtWjPAKRygwycvn233oiaLVtk6WBrVJ6", - "IWab1vjWcajzjMNWz4jAzVgWPLdX/CIo+f7581yTf5QMjL93TqcuJGHiaMoxoSu64Hrn+57WNj/0Q+/b", - "ih18f8PWb1hTqfiEd8vjna8SvvFpuI6A3PUKr1ifwOJ86kVkF9WBmZu4Apot7f543EPPJys5Unzm2jew", - "kKRQTCoyDmv3IMYuH3PDUszMYULGpcJ0piEuyv5dhTONXczVWIGPorYbMG6kjHhJxhFkxEi8gir7WudL", - "Usii5HWOc2pISjX0zTbxSJel84j2/Gnr7fEY+vSv0M2H9Mh+Qikmrtp2Zs0LGHqshjaim03LHW796DAM", - "dRR3vX4bQrUwVLXxm1dpCTDHx6/evx+dvnv79tXp9fm7t6P3r17/fPXqLO5b6SfdGXgXFtWIiquS95mq", - "AgZFDdQKGek0XtlRG1QiPrBf6fC9b3q9LKChDsAR1sJ+m5EsPuL3JyEXIuRMYiLlZQbkzIdZJuQ1mHSe", - "kL/++D4hLkNQQq7MkoOeg33bYvXbhFxAxmhCXkvb5xruzLV92SakcbsT8gtMrmR6Y7tdUMGmOMNLBVM3", - "xjszB+XIZC4VbFc0Ns6mhRVJjZAb/Y38Fr53YHpzmXB8mL6iI1ju6clvc9Z7wruV8PpDe3qKu3Yuj0xr", - "QwT01jQsVag0yglO4x9CPP1uRGnPvBE9t8u8m5F362ng/baECLuhHcnPyV7bTjJ3HtoMMQcPExlLHTVd", - "OPGn1O013ZvmaU/dCqq0pUOFy/vnCBImOIhuF9MjBRlTFhk23Byma1ahm7lq5RQTbjoIwy2FuSIhi96o", - "QzXx6XQQ+IKp4Fn/b6+uE3L57uo6zuAKqc0okJ/4mU1ktkTWYqE8u/z5unqkJXZx9JYyTiccOliZW1oc", - "X13pesox1noCU+mTGYVeeAx1vt7GZuM2qhIeiWsnpBTsHyU0I/QbZp49h344h65SwrZIWE1w1ghCP+at", - "Cyk07MC9XQeiIAVMuOyfia/tpBuqy6ohor89FG8zcN0StDsiVoaoYWcl/G2EgcYu7KWBHtKA269PIQ6s", - "nswjywMWO6OH5E+ihcY1OcW0a1Of0oxcnF+8cil7PqlI4GfWlAn68Dov4MjAOzZJMznLu2h0tegAsNoq", - "xzjtzjybm5wnIeWn7YgZ9vdvxd89J8LUUSZWAs2rmf1Zu1YklRl0ZD3EBh36hiisRraLdz8l5K005LUs", - "RXZ4X4bpV1JfxI2c8ZLO4FRRPd+gOS3oDL6xIqnIQIGq3OlS148cUEE+DE4WCbkStPj/fRgEp4JDspi7", - "xI610iZ0ZkYDn9pdWFpOyi0zJO9D1vFQ7sCP4GfgZaykEUPgHefs2VYph7TtPnbYOwxZSsZDchoiKn0a", - "yTC1sQU/JoFiW/bt6+f1VZZaAA/lzqsnsefMnZwZvZQ9bjwhV46eyG5Guw2ZsurcNk0aHzzoG4j/aRNi", - "YapQOnMOMFL4p5Tpvv7b6VR3Vis7iy0HcCbzU5cV442kWQ/7ztm7i1aHkAjU7rcFOMwqiAgLRfmeiT8f", - "655HF7W/8JsvfCbzkU+QgqaRJ7/73af02CaRrBhV+xahFM4jLQ/JBolzsPF1WAQJzjXU+Exta1dgavcj", - "IQo4NewWj3iVHzuXsgP7TsVTwyyPh0PyswYyNtplX1u03Xsi0Twr+99e2VZJ5A1GnvRNsuDiVDqSLLzw", - "2+If6UjSMA6qdiUwoG4B06UFSHM2RT1VrTi8Zbqk3O7OhHFmlkPyiqbzVgfnuef0dC+O/Kh20erTEZW9", - "T0I/GtIObXpi+uGx2eLI9szVZV76y9nCrYPTN1eHHrWrcNRLULgBIgVyzXLgTAA5uTz/tExsdXl7/tUP", - "9+yGfWLMexLbknexjFRrWwkHbSE0CKOWa36hB75QwnNkMy1yTApQmAb6MBo82tzVUQaGMq53j5YN16mx", - "cYQao9ikNKC33Dxc0vrdm9NspCC14goWhNyM0q1N8tmUUsic1wOmakQgweSAPnIJgbuUl+jGxDx9OH1z", - "FUd5FBciAbbNcXUqVVD24CvYntUBFpa3OxE85N9cHcZZ/xpOem3TjtmfQyYo/L4uWtHaoirZdPR1xGJ1", - "uKOHV9/3GLZuD19ejWdaWbCfSx1I3EMISout7OKNfUZpQ7yYNy05uaTMPnPenF7+XvmFX9eeT2zhE2nx", - "1OyheRKPzBZ4WtyTDHucrlHaYfRDybBPuhSlPiyrwYf7/+b0sk64yabBCNKZgH4UJzb25eViINbh9sqK", - "IGTWTTLP3l0Q2yBCNRvjxHXUTpHTMe33+GPfib/0DBuzwhw5k4RPgFSFhl2znInZ0QnncnHkTPjxLBDs", - "I3SnR6UKaMeEXP4pov9R0jY/qGFvc39pQkQXXbsEIhW5ZRnI8FNHNvenZXrNqVka5tVwj8/3cKCYcHZv", - "pred00m6/ZVfv9xXFXk8dP8tVHjV3PfsbAs7k/TJH9qts/idK+dQxqzR+XNRzdUlgvvd2GYFFJdYY+3+", - "Ir3wcO39JadUKQZYG6QqBDB1tTSZQKo1wVT6hvhyGL68Wijb0dTErRas+bTUYWW39jRiM42oD+uJKUXs", - "XHaz6N2Pq4uA5a7FrtWM3sKCbK5oRKjWbCZ8iBFeiS1FjQqqrFjcvZ5LbLC+JKxk4mtjN8v4vPTBSW4G", - "kYJGuiMh9a7Vih6tJtGntazWOGDko9UFcl6RDcmrxqLeV2GzvSWUdEZDcIchriqLtKJgJ3N6C2Qizdzx", - "ucqPSLdxp2VyqSzQTJMGeGeJwTIp6D9MzkUGhZWGXcGEZszhS0KJZmLGgdgWLmmC843KJLhClRPklcx8", - "Sh+PvZlmV37wiUw113TyrgCxwegoYFEJOIZO7OPQ0xN0lMDOTrbxmZBCbOi1dF8g7iNeu3760LkR6+DK", - "TlupwJiuo0t9omI7hVCaT8tWLtFtkaReXmrHkDYEp+pWIPpZuTIWXzokp1LoMgdl36EufHZFTsPaVqGe", - "0RxTLhnMQ8iMldUoavIZ5TvFoj6WVNY+5b1QtvkSGjoZObz+pJfvHjIZzjIuOV13eVjZO4zBTv7q4mWQ", - "AlyUiljuKmTE3bkCvxOw4MtqKDp5EsnDMMMj6h8XFcU97bFtKqkUCUp8MlExJoBqqM66YTyaFxinxuLw", - "ScFOKeedFNoSHXekORWogmz6nf7lgijqsrbNqSCZYrdB2PBNEjKnImvEGbvaeEdOn3lEC+bTPR9j9hqF", - "5I+zKaTLlEOCNcx9OT4Uh/zr3U3G12+qEsbZFo2q1VM28/ahIbmeg0aFJ8mlNnxJCr8BR0xkZVpl3CuU", - "xLLpmt5CQhRgJXFfNeSwtVg6s5TCFYPQvYmuH/XBhDdyfHvS2016/XaNaMFGFqWfkvh2Hc3u2abx8rVS", - "Ta8tpMozTS5CkVEp+PKY0ArD3R1OgxpI2nemf374BwdKPga141hwnoxTmcHYn7Artu9+k4KMq6FjSH/f", - "7NaOSqheRhw7nhskxjMuMH115qpnf4NSpPLvIUFzCOsJWebtd+g4X03Bl0y9dKTm1R2kVvq7MlSZ94FE", - "jXePP7EHGok/qcxv64QxtM5ZlnFY0KeMstgUBdHa70YsRM98YJdK3i1fKSXVBiUnFfZJWtimR5wu7ca4", - "cAciJ77YaztRxZC03tD2F8sEJAGXknsutTlCeO6krZgchvn+7s7np3BO2XOpG7FH4aZgFMNfj1z92SNc", - "xdErV5zdRYEMyRu5OLqVvMwBeU/oSTGoKXObTc6NDppr9Ls5asZX2M+W8QeLKfMpJX0b7XhvyJbjGZGX", - "EVw4pbZPgfzY7V3YNVcFFUMy3P5hlvE6OsMKVgFAKrmvr8OqWreeIjCNTyZBZGGOmCAKpkw4s1mI2KKL", - "lerMbSZJMpjSkpsju15u6YSYEc1mgvJPnDRnBQ333LKbW9qdGuEFeUo+GTmPx7YyyQyi9bIrIuOogG13", - "vOm+q3agZLN2YCdVaVOtDtpUFS3OGfbElAqoq8PbDBYnjkkG2mBYohQj1LBBltgWtywDNZpwmt5wpk3r", - "21IooOnc3vvEQRuVokoUkJCycIQDzVWyNI1vMqFHno40vm2n7x2Sn8UNBiM2t8SRHV9OoV0ZO7IGd/vX", - "FtH8urEK93V7Gfads7KO5leNhTS/3pqIeKcgWJ/qrAriRHaEW/PkIaqbwm4fLSh1y3p2kj6q3InuxjRu", - "y8H3z7893FUwwftdTWGjtfUK1C1Lt0ZlundlhpeXpUDgjhksDAJ3BaZ65cshOUehGcV4X2DSiYxOTpLq", - "qE71ouelyeRCHJJMoirQl8dvqgn/57/+23HqehQcV7sATFC5j7FGk+/RjN3CUVn46lD4+iSZ7MtM3Vv6", - "obw0spt7ftrJTz0yfYLgyq5zucfT04Jovz1XllG/PF/dMYM8FBHWCXfICLCc310hdc0HS5GB4ktLvdq6", - "HFXl9k/nVAjgKILivQivNXshHVk0y8S5fARFESnmVEMd5Vl5MhMmnLb+AOlYxTkOncvj+RlOVPkQ6dgt", - "QsixGko9hh6SMV7ashiTHKjQgbfjwjNm98XZKRgmGFAE2Z19pFq+ys18GSRyl9N8SMb+cwBISaHglslS", - "82XVpzVCm3iNZ/QWRvEJhZOoMsf7GFXnK1Mlq8dTNq5kklH2LF8SURdw6EIUV8hhyprO8OFYXf0jLS1r", - "bWRj15UpsbpLbjsHycDvwyAZ+BVFiVoRfYqfn63FBLstGJKTSZ3sKLY3djBSFuvVLaLb5NQpXArbtco1", - "T12JvMvzs46EB34DBc3j8utM0bxdTd8vI+yn12FgGR5W5uOEjPPSGFD2rzVNw7hPTZHmnBJ/KzaRImQ0", - "72T+E+tUL1/Pgbxhorzzyg/y7t3F0Q3jHMuAIN/DFMZ1fgOhWebu2l8uhsRxDl9ScPwsg9tnN7mejYMN", - "0KIZFfV1QNArmujANHLIpVpWB+rM5yEOxPujVdHaupx4mBBe6J7c6bKwG6X7pzl4JI68tt17htzNkHGz", - "RlLmI4sST8mQ48eyOz+281xhx+1FdBccTKXQRlEWu4G/zNt3AVKWOdt4uIpDMhZSQGAXMy4nlK/flpdk", - "nEOeNthSOlOyLEJLPH3EjjkzL8k4LUoNZkyeYT+plqNCcpYunTH97c8XJ8/cF0eZYrf2BcI4r8mzFH7K", - "mkieBW3T98Pn3h80Y1lVTNjXqVZl6hKVjKXMcWnHY8KZgDaDsYvFzC95anmLm6f7op5lx5sxH00VwOhm", - "EikErQCIN2T5LWGC/MR+CIW0m8EBdnIJyUBhdrRKQzy20I/fBr28z5Hn9uEbTS4gPzoXU0myMi+G5ETr", - "Mkdl5B9wHKeUYB9hSM6CY0LIIKQg5ZTlqCRMrQASStDqnHLu1R0Y400Jp2oGeGojIw3lo5vJGAspamNx", - "1B6/23G3WHvkdigU/MicqgyDPzSWx/Gn6clIQMLm2VGXDRJnVi1Q+2oWeHDr1705tQjhsr88+Cje4n5q", - "8v7kwmHRA47jaXZhm+TjmWEQfOIw3I8dgsipzPM4NIIxFT7tUJvdHuT0jrz43kr5SicNXtFq1mFe0Tp6", - "pO9B47uAaDCO2cRn5Y/5QJc4byqkOFJaOyuz+wtl23kOuf14OCTXXgeOouB8qVlaU7+meGjRvNQo3MWR", - "qKtqfDEyVN/oGJ4WpBYyJlgBCld5pMEc4Sr9ULlsauQdxmq39xak89VbkZZaqDq+tlNwL4wx8Z6Ar/LC", - "LDchpXf4sG1PKT4GqCHfY7gLmk8kmcgSXRgd10JkR2RlBpx5cFfBxs4Tbzi9O3cwvq92lSpFl05oYbMZ", - "qNG2C+DbNZ6ifa6iYyZUZJaSjU8vfz4mb60kb/+xF+I4WIcavCVy7mGOvS9YhWhorKKcS5cNr1IYNhLx", - "+nkbSZi4lTdOYK5l6yF5NzX+eYM+o1STcXMmY3LQAOMvUUMpCOoQgwZSKkjGplNQ9XvJd0rdNP3Pdk9v", - "WWpYPiQXfe5/a9+66qc0987Ru4pE9BXJEKF2k8ZOKidYfyIuvmvbrUIusCab9T/3h9DNbTdhIw/oT3Tb", - "XHSdKvVQ77pD9Ce6/SwbrlqbfMeaKSadw5Z9snlVrMPsKrtjyz0yGUxoemMFWZGN/DfhIbyQ6gaU/WJO", - "FWT1Z8xUHZUQw6yDw9Kpe0ow0KforXQvFxGfX6/2ggpGYg3GMDFzz+DgFtX5SKCFSee7W+BW17L0K1lP", - "NnrqRiBa8lsIWhIiS5PKHFzq0UZZ/iecB2fgIqBpOn+WgcHUQ5U2L9g/LPo4I5wVCXiomB7mqaXzCnyq", - "SboR7PYUpSEHXM4SsqBKJM5ocoizsiSgnM0NgbsUCh8N4uZnlOQPmJ8D0Dm9k5mVQ/zLzLvAETqjTGjT", - "chD8n//671AcXR35aaE/kk7IJafLhcLaP6g8hjtIS6d5qctt6YSknBUTadktxcqRSdPPrwYq85yKLCRh", - "v4WVY/T57g0o+tQY9rNLLFINFfSfByln6Y1OyA0sM7kQGhcquaXavyaVn8PTTaxZ3utZZZsLyTyHLoBp", - "9pR4fYlxx9VlCxvTDFpxKf9Xkoe9Ob10m1Q5UD4loeJcN31evb5xzdW1nYP1oPJfbXqtJoG59vJTPRyS", - "i7h76ksip1PL670/jauJgF45uC+NsntPeHqhXuOkXSEv1MBr3tsh+ZHN5sR5R22dvdOBPt3Mf6gdlp2N", - "JCG6TOdW8pWlOZLTI/+mQzWTy2rsTMFHQaXuVOyWwP66QSDpmNFujP20iRNOix2EziabdyNEknO4PLsx", - "bR94DGa6lZnXiwzZkJwL0iwvQTRwX8WXaX9ix0TmzPioMqa9NurA883FXKISyQE/JBzorXemq0aU06nX", - "L9mx/OCawB1Njbf3pZVohPKlka7UBM7v5Pr0x0YBjK7ZaB+kRgUBfMu60yLjf/46PkS/NiLkkSxetien", - "wFg+hqYvNORZEdfZ3q6lz2FMpCIZ0/gnrbveMupml5ClLEleuhpFGU7hruAsZYaM7ULGFsIYD3/ceutU", - "SvFeSJYVbU69G5pdNQVE1GqlWTHyjLSy2tkfzuD2WkquvUTkVDsRKdJlzoJs5FxkIlqNC/eDPVDEDHv9", - "gmjeHt456wzJu/Dq5kyb6mAbpyrg0BW/RRIEt6CWRJeF1z65mQzJKzu1KgiscY+8mBzcpgUJiwgihe3g", - "dJsKOCbW9qFjgYSXIp1TMYMsIcw+hfKCL8PrAu1/vr7FzxrtvEaih5Rze2azOWgTnEj9tqHYND4XRWmG", - "GdMFNen8Qpa+hsE4FMCmZF7mVLCPdq6l0uhLY0mbxS1MBuCvVl0ndez1sNST6pRq54ASEsuYUongatS+", - "yvbW7q6+QRTFbXQnX5tjgsKmF6LfB72va1IZoaeVG/HVOoVrIF044xtYxnAPZ4zo13TNDVxOgzkmWN1y", - "ASjnVp7UlHs/Ce0zwUmeNEqmJyRID/5ddjgkv7gcdWM/o3FSO0c0iKWlO5Zgeh5wjGQTbSqBxr8kVCyd", - "lV16D2q78OkUg2Rc7fYa3oF/6yTByzfBF3TSlG8PEzLWDRTD4MggwDiDToT9I3WcgN1y9JUwckhO6uX5", - "QwuZZd2E/apIyoEqR5pM/JTdYsa+KnEjD/2BK04fXJ6dNHDoDPWmhmEp+xwUvMQcgFwuNKGlkTk1Pg5z", - "MQeBHhu0uWVtbhqxAPvl9Q1F69QU/JoM4M6SuF0hvcJeAUrPy3dvFrMuySC+Vg9BL8KQK5oDGfvjHRMN", - "ORWGpWhIoGLpqn9V7RNS8FK3FRyNy7r+Jmw/6AP7ihnos2Lno1llwvZo9hLY1yGBtW7TblfjtVQL6uId", - "5bQ6/wY9M9LN2oCy/KLhQL2WpgHdxhtYdEwccfCUnZSCo6ba7wBfVpTU0rgEBRSnwnf7ZLs2xkPQ/iZ7", - "FHWIczAtUSwqOE3hEANFPEvxQFw+CJ9ByH9npEO/pvkhdGsgnZVEbMOXhOFwiHONERyaNZE7oIoX6Cqx", - "LHbRpeE73/R3128udyefa712QxPb/Rkqbvz2Bap3j3dfBMkYUk+LiLVcWx81saO3Me9di5MPyY/UibjT", - "qb3ZB2GShi41YcIKCLdYsgYENtuGW71v4ql3bQscBjBUY9c76GPM0Hgz8YGDwWmunhZKyN5XQnsPE+f1", - "6YMh1o4iB629Jm3dGhN3KK1GGznQOS20e9egX+GzWqfkPWOeWdIg7JPmmY9afmYfC5wuiRXUXla5cTxA", - "LIBpKatPGWMxnhrmU/A3TB8rM0GDTBNS1LahDUQSub0rvLRb76Vt6LRr9ebV3p2yGIX9d579yjS/wD/B", - "eeHYrbZSkN8EjEh06w8NWZmPppzOtDsfu0Xbfb3CmsMRxsxPp5ylN/gi8xU6d8ytMCmNiSX5RpDE/erM", - "t07IRim4sU8cpmaQDFB5bqeKAaLeWuVc4uyNjp4T6qA7gkKuvZ0M23i9fjOERy4ExtEMPJjoAHPJs9EN", - "LGOPf5m5iBb7s12fbRtes0h5EGrjhbmezmHF2C/KfOTU6m44pEqD4xerN/0tRg+jpoHl4C9WAd5WGcZd", - "t37era/irySVqOildX5bt2OFdAEZUUiRGoH/5z6QVtD1bmBBdyCps5v45L+7VlmKlls79Uy2Nspgohof", - "SL49X4UFGp2sNxKd1MaZe5jkg6nJ4649ZWdVQiGjoMpX+0VS75+IdirBpc7J2p7EfxA1lMJle3WmJ8cj", - "lTOgoADnettNQMHVNvB9C6poDgaUfW688uK1FNXvrmcrwgut1eF17CPh4t7EeJVzSzO2iTLrBOvXZJAp", - "OuvX/UzR2WrvXN5Cv94X8hZWe6MPoCUT2zpf2oY/wbLR1xnNtnW8wlbNbmBGTk22tSuYU2zY7M0BtkqM", - "V7aRR+GG3/G613vwSFjDsBYfbpxva78d5FBqv97KamtaZ9taeVhIjHLXQLcs0/KJa7gz1fas3vJ47eBk", - "cKqAGjjD8tFSLe/HPPNoVG8laWQBOrENyYFM0d8TV5kQjIv4l++/PxySM8cskBf8y/ffoxBHjX1tDY4H", - "/+/fnh/9y9//+V3yh1//VzzRo5lHAggnWnJLbepJ2IaoH8SlrwzybPh/bXdzsiPFNvMMOBi4pGZ+v33c", - "soQw8QyHefyJV7k07jf7mEvT+VpCqjqnUEi6UK0ocSzBVVyV5FnV9BlKnUNywos5FWUOiqVEKjJfFnMQ", - "Q/KLfcv4V2jS0veuj8a0Hy1bRS969PHk6D+eH/3r0d//9//qlwL9zEm3PZ+RK3VTUAHdzc/Dy8G1qzPA", - "dyS7nyrQ85GiBraD9K2JbW0B//iRHOR0abmbKDknbIqq1wwMpOhPehgddMGyGL6ujobNNs4/urWrDO5p", - "5HlLlTtk+UqGd0J9NBwI7NumKeY+X5WEzmyTtUJAEzALABEmYuV4H8RHXUIOI4llL4RyWeUKNZjdOWeC", - "5Xaiz2NnsjHbj88Sh574db6f1bkF3a29uZg7gM5wLnkVhadzKc38z077iIYZtOAEbbwV6O0aJlT7Grc4", - "IJIvDmLm10Hv3DpePH/+/HljXd9HF/aQR4xdwk5vmDghfqewJIEze8op+dtdQpZ/b74YCsqUrs4uVI12", - "SV7sJGbo9n1hJUkvmhJqCAeqDfmWFJJ5T8BqpqtTbsZUVB7X3+Lm1R9WV7PxR3eWLRy25xrxoHK2zSPO", - "boD8AB8ZlnPDPD4Bm/GEF3TpFkKY0AYopmXgTAD1LliF5F5zhXQbR0MdhB4VoEYaZohp7jpAMcJLNspd", - "Jgs2E7JdFqIRtNZq3lrS9zveyypPPc5r7QTP3SzWb8PW+7m2zvYj+Xn3K7maEuKWmxfWDPP75aNdkEx0", - "T5BcuOmRF625vtjuB9wlO1Ravr76thXAm7Q6r9xTsfZL7MsMVh2jnUN+4/G54uoYCdHLOtQxror7s3+n", - "t9T96Xwla9juFYtfzqn2LpL292+wvndCvvHJZL9xj9dvvJHsG3JLFbPs1r9M84LDMfkwoAvKjKuRPJNG", - "HnwzN6bQx8+egWszTGX+zeFLogA19I3mmAbz4PDlh0HMe9/VL3F5rNMWHv5xDQ8vHLWuXUJdOE1d+aGS", - "3q2E9cfnLQr/XYu+b8c13Pye+KBxwjuig+1k71Snw+u6Jj9g+UpILOZL8Shs5aZ6f3wum3giGD/p9Weo", - "C+xzJ1mHEuDkDlzG1kNHRjJQkflchTgYl8ilCoFoLiyiKM5krG5fBcz7PPeEViLCb7K0QnO3ISO+y7Tk", - "fBk3NrTi3v0AcQTxuuefCy5pdlFywwrqbPKrjlGVljqSScIx+LqNvb4lgsQri0YUK2o3WHWfMJLXLjQW", - "8wQYSdBI53fUj+S9gZ7NZQ7PXARJbVXQzz6Uz59/l9r++BesPGFOjv6DHn18fvSvw9HR3//5Ivn2++/j", - "j+WPrBih8n1tiv/BCkJVOrecxsso3mBcioKmN5CRYOhpzPrA33Xy/XNywX546bRlIZg1p4JNQZvhf2op", - "DpsR4hMmqFpufbhW0/WRKLGjb8hDz5syz4uIe1MTdgMRYmBfMw7nYirXj5jpUcbUZjxHiQjN6pX+IS66", - "5LKzwpoVDnMUcX18WSh8U+1iRg0c+QKM60HQUU5ml+XUMRNmfLqkhHwYZGpxp47sfx8G9on9YXCkFkfq", - "yP73YRAPZorj+g9UQysjBmbxRSeB9Z3orcYJr6B1ssM+wmiyNBC5z1c+Fgp/HvpibmEaDHSPMKgQ0kbx", - "pdgYLAl40DhDv+ld6OTi3ToycLyu0ys7Y3kdCLU7+lG0AUPWHw/ve5bVUPc91N2wJK7H9QkqlgU0lban", - "71+dXL8aJINf3p/jv2ev3rzCP96/enty8apHsgmXZ6JTBP5JyIVYc0SJn+8Zs59CIpVS+Iy7Vc2Fyg/A", - "O/uGQu9eEnC5EF3lvjoWmlbZFCgnht5JIXN0nfVgXCanpmue8+X1savjjBrq/PukypHZSVGdNUqldioT", - "4HJBDpxJxk3J2Wq859C4ex/GCVEwoypDrxf0j5GkKCecYZIcZobklHIO6qj+0m8AOhC9u7omz6rZP/M/", - "hRQvVT6N4DHBtNvZl0QDkPHKXCoNx4IpIHpOC8CsjyyrEiCnOJkQKN2MpGK62uAQhZ76ahHf6JBeL5jw", - "UerO6hN3ImROi8KimZVaQ/brzQ4vrZzwSQgNHKVzO00xg1EQKTd7/rpup6GXk4NXgWI04E4Ar2yPTcB8", - "ZbQdoF25HhW42g29t3dzuy/68fbpiw2bfe3x9e1+VrWtIDhnXZ+7dQsA17aRmLnuz+WsX+83chb6NhyC", - "nUV+C4Tzuj1aJ2Nw0D7YF8pPsIzBcCaxquRNb3DOftgq45QMOLuF0S2DRc9DfsNu4S8MFisnXYPpfd4B", - "0vqhex/nBqity7xwXc4aPVahMcGqDKm9gJ0LZl5j+1VQClYyrvaC9z702gJ0Z3jrsJrBc31A1fEUAVKz", - "TtYWGD7r6nnGYbW3pf5MzPptk4fzxvVpb1IAqIICqw8kr7dah+HiRPsCca0DFCx6ExJfbi8m1MrP6nuv", - "V0brAehM5t7t5Q12aUFs11PrU5cWO6zQghaoOZua/oBs6zaYtNih2HvVS9Jsl6q6oV+jMuTOVTfXYeyw", - "jx3l8ZK12ki7lp0aJJEaH7uXUKlyTPdhoKt1DZK1RK87J9EdJGu56XZN++fTNtmn2/ItPq/cC+LXZCAF", - "9I+tXWXwvya7dGtsS8+OMSK0a9cm6dmtb4SK7gagJuc9+61iT99ukRu5Q9c4WdwBQE1Ldui0cld36Nm6", - "HLtMc5XO7tI3UNndx2sStXsd6H0gxAXp3TtX8vPuXSOyck8gHRLVbr3X5djd+q+Jhvfsfg/y0SE89+zd", - "4l19ES7G9/pS95X37i7dGm+W/t1WXzs9e0afXTv2vefQnXqFe/Rvajl26x7VuvQEEZUp7lsWwBVheMO0", - "QVVzRC2rFF0SOY0oeZlwNgdMoeOyCg77hp9XhpSIv00l00QKQHA5W03oRouCe2PIxkCfFUOKnFWWWgN3", - "8SIdVXbjiHmW5c5YV89oQXWVtKyvRabD/aE5dEzHfEGtPPVbOWjmVN08onumBQdY3YpmjTC3Tq/NHV01", - "u8wYbxsWDDeFhGCKRl+A7uLyDySd08KAchVEvRfDG3RaGxx/6/0YwucX2w6301i5cpq9nBj6GJibK3S7", - "CJlfahTd5XSqwUSdBS+VvGXaeXC7Zu2tq69j47gsIiSrXlUJyYFqjF5sZiRzyfnRjQQTO6kbZ9lE9xla", - "mrlUzDiXJz9+0Lf7I3IAFsoiFjrSTZmgnH2EXknI4wa+ekOixyZLDZc+Euh9pYZZtQz3DVEKAQD3D03q", - "gtA7JGktEmQ3LHxEd1MMjXigo2nGtKEihZb30fdP7V5q57yTe+nDfS69Qbd2sLR/UmFWdjFu492GnrX/", - "asAwYuS90LQvpJ3Q9f7xFRloM9oWJ9IIhA7OBtvCLJKBVuk2wK40QW+Yq05PYYCksYrYDr27adKlHbzi", - "/s2VtCfvfqqKhK0LV/JmK9aei8zKZqCDW9dwu0uXvImu5ZKadO5jLO534l1BFmfdwRUVofj2D893D7U4", - "6wyxGJLzaS0FldrnSPD5puo6SK5LXfQN0cfLQN6l4Y/Pk++eJ99+n7x4/vf4FHFrve1j23lNvQu2gqml", - "HS7AnX0ER4KrPKtWoqtFPl+H20pwmFAgTml8pHwdL74uf9ajO3ZeOZe5cj71+oM7jJEEhJUmCDOEZrRw", - "8WICFqGWQu2HijiBezkHmk1LnrjsS+Eb3oGenbEtZ50xLRXafPft834RLqtxlPfjvFuiTwLXDWzLJaZe", - "ahdyslrbuYGi9rifJ64tVUAMJpTf7uC+gZFWAYH5No56A0tXk4Jouzmeo/dnsPHxg1unha6X+URyHBwH", - "GpJXNJ0TOwTRc1nyjEyA0EbbRga7yZLcZdJIyT+IAw1A/vriBa5lmds3DBYblEIfDon34taVK+SHwXv0", - "7f0wSMiHAapD3Z+nRnH31wn3X73+/sNg+MH5MTr3fqZd8EmKE6RcSzvLVOYTz7K0j6d08P63CU58+AlH", - "+9/XdIJgd9jQFWqNuxul13WB7kdz1KdV0j+9FJaOCCysts6aqJq1Yz7+Fsnh7CBRNStzWI212YpVVI+U", - "lO2IjfgyynalMsyYZruSQrFbxmEGHWSH6lHpk5FtBokvVqYtH8GXnSi5q7/qafx6longPrPmNIcbHTI4", - "6TnwKk0X8oIyXmU0XcTS2kiF5dRqjdEBbTr5HXqI3m3KJ3MUsQVsl7lA3Haj1z9jwXr+zP756+qBvRK3", - "TEmBD48qCAPrZIGpWPF6Tv4a8/8/9r6+uY0b6fOroFhXFek5kpIdZZ9du7a25LdEFztRWfLmNkuXBM6A", - "JFZDYAJgJNEub92HuE94n+QK3Q3MDDnDVymy8jz/pBxqZvDW3Wg0un+/hUKKzWon2hewvUQCl3OlGu5U", - "H8GrShcXLI5jUQmXngdfx/G3HQab+Q7ErXQXrczRyPgQCC9b6PugXOJi+Kej5tzWCnIyPsqGxWjUEjPB", - "col1P6YL1/6xL+2r96MsoRI2BJFFuk+QXhVjaxXprS8Zol7WjFrn/PX7d53l361m2NLjP568fdvpdk5+", - "Ou90Oz98OF2dWEttLxHi9+CKbrubICcPOz3/R2+ItQut05DorEFkfxI3Jd9vorNiquyqwrlux+ibVd/y", - "j2xYgQdf7WJHl8zYWc5vVHXC1sLvbti6F9nbieNGXDg3W70LHtPTjLPciiLVvTj6vdPzf+zPG1b07GEj", - "itmC1wJ3pJbtsnnRToDsOVtYOMLTqwwCIorzdZsbLOlCS/6x7Zv50siXXV/XLez5SeXWhg+9QeLM+q8t", - "04dGhpqfz+JitTGlBg6gptfPhLkWpset13uRVhnVGzbZGMEtCpm2EJ17d/yCu+bLGmSpXOCNpdc2uK9p", - "VbXItL4JAGsFPxMJ2PkSq5QXF3nSML7X1skplBC8PP3ACrjUyoVJhHJ8XN0FFRQQr9hGS75oWec4mnBL", - "jOvr+ChI9NdShFP2ONCmBdY27H2sz2nZwRvDLaflmrpa0UfJRYzdb96L2hc2lWq7TecVd9xbshsjMQA6", - "J3pYUSkBSXzRfeKOr+VYpNVWVlMFx+9+XDnmnfxF3x1Cr7D+c4sjpNuaNiEpy93hgXC50++sG1KhoRjB", - "ywKrTXyns9eRHc+I3AjrLVSFGp1KYbVZ4EzZdTXjdVopLAC333j0ab4sf1vv0kIllFeFRhyTtUxDNKT4", - "cWnZAF4cdNpU1ve/YRfAQDhVIOkKYXEyKdRVHX0SKpNjvfOaSowlRLD+u8UhhjqdwdZEVUkBOhknQJF2", - "z1dV9ZeyTDeVrJWw2zFGBnGK9FpabWbPCCH/Sumb0Dqh5AUmfmEYbqtzsNK1e9QMiW4QQ8NWsKH77ASR", - "iVU2wxtx32ChsMGksM7L5iwXtuvFAGOvgF6KNqZO2BvIuEoCpW4gb6vSPZWsWBUSoRrlWKShqbHpxOqi", - "sl5gKVd3GwcBziNpe39nYu4VFYkVZ2e1vW6Fa8OcAWGaK5JHUkHp3DoeUXlpH95q84dWhpbQ1Vv82cYM", - "h8rfa3Ata/tvcykGW3d2bp7Br6z2s2nOy0zI92K8DhTmeldQPxABRUjWGFM8ZAnKV8ulxC9wGbHJh9ZM", - "UMBvfeNPZnkvEyO/ERgldkpZ2OCbjbfCYRa6YWJXLdk2lysmLvQKPMu6YDTuRnXUy00vrDPHL26X3/H8", - "oI38pBVgKkJbjE91oVyfYaaKP0PD75YB1EmXKTHmtd/9OjRv4tiDFRhnf/c9TtZoP9U3qqH5Im9ufJek", - "jIi7uX58f5VWcEdI4yU4aL2pzZVi40+unSmxgJi6odWSaSrUChAXzOgor8vopZXX/fRcS7ffyEycCjOV", - "kPpnt+v/2Ogib47BwZ8IzcCw72uBjE1hMxqgTP90dLS/GXKpvlFNVz6+r/AnuOQJ/f3Q0t91IBaw2j8v", - "5xZvdvESkZgZtkQVXQJ5UYXg3ZBOlxdWVCGVkHcvF4nX/TReI2x4D1G9FAfs3aZriCp4VS1/7HClUlYb", - "b5wQ78K8sb9wl9wpUGxE8YXIAABqN8NPecWV12J1CDdqO32PxXez2RppPa1JSjADO2YzjwyfiuYknPel", - "bxse8ks8yr3GXgtjZAocOnBsohnYr67508NV8eDG6Gg4uy3ENeGoNJfTTKnH/gyJeZKygrCF7EplijUT", - "KiVUxT3rdN6ljGy/oSI9K4LaIgsxzzJ949+aAv4VwLCrQMYSv2nvDFC3ElHdKEt7ym+DLp6oM9S99uvT", - "sunq9WFII12+sEvXcspvAZZHfhIn6t2L9h5AQUQgVn/3Yk1hmsc3fdKSVuZHd1ykUq/Wy5dEbcf944gR", - "a2Uq2LVMhe6z96iDthod8C4SvxaMK3qL8hG9vJwWmRXH9GtyJVyVcGbPfwTwZhhwBg21m1T4ZvZJWjDV", - "qp4OLi32qKdVq71osA0639U0aJMI/53VM3kynYpUcieyGfOKFWkXx4YnYlRkzE4K59WM0HamkNwHAU9g", - "QUq0MQUQ5sFQQUaaL6t2KL9Alf990LF9W/mdoGOXsDvqWmQ63zQj9RxAiPFVFi+NnPY+QAUxkM1BBjXQ", - "MIVw6VII/TpwE9AT/NZ649CbaqWdVjKJKWoMr1rKnvLEaGuJKXUkIOmDVhmVEglIITvoLbeuBy33Tl5R", - "DmZB9UZnZ69DtJQ2CGkRLBjjbgulDhtcKvsxhnjyx6Vr2FafNYdYheUbN9KIXiauRUZhNkBZAizUvIJm", - "RSsXdzewRgHxijCrytH32bEZSme4CcBT5HkjPTShWJWYTd5ApvixPnujTQTZWg2t1W3CxIIeC9ODcB6K", - "DUt1AqlkQJiJnLkUH/wPAps6mPvlFXy3kibYZYuIWo3kIusGkR9LKLZczf919vNPMRLbtFSZtDTFy0HG", - "EHMR72/ml65OENO0KLimfu53DQabQvnlaLwDd0HgaGeO9yp4DQT0MzccMgbwI34AI2K/Be8jk1PZUtvh", - "GhyoD0reslhdiIcdb5rmgHvLiSJPEQzWTWX3WKuu6vcKhce1PwtXw1tcwrdx1S5ml+Z5Jlti1b/wLOsl", - "wKsYqtkoqFOZzDrjsV9f+iQWNrkA1l0jAqwS4K6fsdAlvriNeVMjW2pqwAe4QO1roniYFoRGrsobW8hy", - "K4H7eATGY1MJbstQJHCi9ycdkWVsKCaS2JswgGIL746FjTO8jua9PoEYrvBHGGak9Qqd6AJSCjhdgdGO", - "KS0bCrrBhTpdNuIWCjwnXNE1Fj5gBE+fA5Si4ClSQuHXAtfxhPtHhWKZtuBv3fCZZXRJ7Lcr2DosUukR", - "+bl0zxkfhgc4PeNfSrkLsUrQ+S4JDS17lYvzkzC6v3Sjb8aa385/IRcl49YtuFbslRbYP2AQraxUw9ps", - "2uOFFFcQRxxHowWYIz3aOLa+G3fHlZhZZ/SVl8IGvP3GpK/mddqqHDDkKZf9COWQlbJAv5/cipTBYPsD", - "VTP1phBsL8jYNBSCHqSBeWW/z86QZzjW0QwUFT54Q+7bAueVK6ZD7KPSXm2m2B789tdDPy9UrbjfH6gK", - "BwTw1vlZm+W4199ok/YssulPCnVFmfRx5FI5w3v+KWzQDpS3FIojECp4OPjn3Nsdi74p9g33Wd+XJUvX", - "yH3abSHi86II8wpMYrilTzRUayAHXguQrb7wCpOI5bJ4KkwvmXDvsXnjNcs1k+pfxENtuBPPvZV1/Eqg", - "5wveDjiVMGdDnlzZnCeiFAJ22Gc/q2xGG5FtmgG2Z2UmlMtmtXkaqPIxkI19nKoY8zjsP2mU+pCNti4J", - "4S9i+O7l6Ym61gj3QPSwG6p6yGwJXrEuXKKn4oISHBpdVhnbvGi5316b4YIIJpYRXcyPcytIAIBIi3Rx", - "W4wIk5uaHEhnCos40mPRCwyIlAwVoHstgr77bReCmdwfUp339ICef8ozmUhd2JD8Rll9RYN/ARQWUo2h", - "GOaCKPlwM+ZMadXjhdO2GE6lY6lIMm7QGYH7MwDl0Ll3ULzPUTjYr/BpV2e39ZoJXGBwklGJQMYGxJrr", - "NnZjdVJ7fZ7jIJcv+9WWBfgwl+3vNO0j5958w3tdPEngCeMJeydfUNUlnMesMJJn8lP0iFdXvVToQ8rw", - "5+pgut9QLsBR/VyF23jy9M+bwW3E73RpXtrn3E/ChlPNldIOq+xWedZlG8eVl+bLjhp1NPfzCZ9ZtaoL", - "IwuQIIuJ0msdZctOVw6ylZWZv+zgvwFTiff5vfc40oZ50b9Cr1ZaJm554rCA2IixtM4ErnznjwJ6CnWR", - "1zyTtPtKZ2NAhVWCACEjEq5RgDQjXePgWAoDYXtUBzA31XGOlkvMcV0ENrrKrBrOoZjwa6m9kzIBzi/v", - "mFhif8H9u4g2FysWEelcKIhtQ6QI41UNR9VoG5vTrvyxClx+b5ibH/FHoQutslnzn2PfAgps02Pz1Svx", - "k03vz/eqWx3F8jV5Y0jsN707bCxReBeORbBnSZUYweF42XSnJ6ZDAfhF8L0abTad+uckeHWdSWGydkZF", - "bObD+7fo08HR0XCk/6qEaJbrRRw7NrZ8cs+i5dh0dtcvPZpfyi8Ldbuv6/NM52tvUSDeADm4WmddbzOA", - "wDKG1DDJ7Fpk4LlUX8IAWj1d8EuAvZUuW0KjCX8Ox+R6Cy2pGWNxsXRh/ZI2fm+jhe52HB/uKNWCOT68", - "E0m+kSrVNzt3J7SLn7uDns2pQ9nNOH01KagsX5cke7nK2LXxV+bY3TUVs62FplfxYlaV6+OHGzttpBOR", - "lH47B3T5WbhWBRNobUKD23LTfwFHCROgSVU7dHlzghG449OTTrdzLYzF7hz2n/QP4YSTC8Vz2XnW+bZ/", - "2P+WSF1gIAcBpORglPFxSLNJGvJs3gkzFgA4Ak+ijopbaSGgp5WwXVbkKXeCzX20AebkWnJmi1wYSPVP", - "uxjCAAq/QjmZwczFp1+Ja5AxNujAlYiSajzoACJhJpWAHP4h3Ct4J2OkTeCSg0tewuOBo7pfQ8y8SME9", - "cckktPIGxo9LIax7odMZnqLjXl8BYDz4l0VftnRa53aDMJtzpi8MCefQaTaFaSUmqn8OOr3eldT2CrEw", - "er1UWm+pe+O8GHQ+7m8PX4Edahar8jnaDwIUErTz9PCwISUQ+o/rjc5aHBot9jzD3Zdu5wi/1KThscWD", - "FzzoJHJsful2vlvnPYABVjyjt4CTbzrlZuaP8iiXsYsZL1QyoUXwnac+d7qd2168i+iVd4/l/aD/cCnf", - "ufbHe7Fab/xZuvSESy45oIY10goGn5qxMrkmVtoMefwzcMl1B2qlQrHN9WmgNlWol8IAm2+YBTblio/R", - "T7+ie2U1MjzQNJGcs8hXeCactx62O1CAd98DuleRxi/iOOL3g6DCPvny1elBAMXTah9OC8NMJ1ciHShI", - "GQhzuVL3T8Mybq/+60ci1ln8PvsxQBDRn/xpzg7UHgHdUDTzpdZXUliax0EHM+EqByq4GsYv4K/9gToT", - "ggUyVZBkUfakP9Z6nIko2AeYfhphusLvVPuEQD9+/C+4lclx4SY/Xwvzg3P5ayhqT8McNHYYXBX/sP2Q", - "jw1PhY1v0bb7jt++jPfx9pTw6jvPvn3a7ZzqvMjtcZbpG5G+0eaD8a7EPzsNRLGdj1/uyvIFWXm0xm9e", - "7PxYdrGByBDaq1OL5to2hVSRTBRo1gybertSsml+Kjk4sUPi1hmewKXgFBlCB2pditA++xmqDcysZPCs", - "EJtClgzRmaZMVtLhvAoO1MtXpzEnjubFm74wh13ixnYTIQ0z3sRORdhODOTLWMzd8NozKsD2wVfAbKJN", - "YTgz1Bm8FkcMKPKpcvDqMDaJLSnvYJaMJwhOB+kjA/W6QtuKR0UQ7QYrY53MsgboubBj+D6HLcJvNTyV", - "Sli7kWuFK132aallnQbC2gMvXr2QN1Ma12Vnglby27UU/ElT7W+cSBRtUae/TR9Ymxf4eGv61CDtu2t0", - "j6u0F+zD7trdbVDttcl/uwNlhYtKV7aA6ifVFsePKPsD9TseP+Z15Fil76MNfsTa0m3YDnGK40xCTJun", - "s69BlVZoD9vj1kuv3a/uknGIaysXXYcfDMMxoVmJXgcEQG/dUwjaZYTgGD7B8E7OYiq2lWqcCX/4B/7O", - "Pjumv9JG5LvgPeIyzpzNaPua6CwNxf23SVZYeS2Y96C7zGqmNBVkwJ0Bi7JrWcIVphlkgl8L2HtCsZJ1", - "OrchD2AkjXVEys5DyhMtDZMRMRczgOi+Eznn+gMVWF4LCwnpflNKJsTLnQrEHvIbZZnKA7AyCAXtW7sS", - "Mwi4hOkaqLCj53zmv0LJoczoQqU9Z2TO/OlDJYh+IAAaU6XyWqYFz+gzTYr8As4StDrHIXV025PE0uS1", - "xZYicNWW/ix8soWV/iG1MyoCA41pVICqTLcrYsjyreshcINegLhUtbG+skBkBGlB97SgZQO7ruM7FHzU", - "oqj3D7qEZxISJv0aolrCnIc+tiQLbbqIGFU98NtJ+zq+Fzx9WYnANk3nXa0nNkJOPi7nXAAgPMOoSdgL", - "FzRv5+n3g8b0slhh1xCM3nK+IcbdPuH1IPs9KU9zJH9bBYLofeDVcLqcpK/HJv6CFwsh5e4uFhS5NFrX", - "MRbU39MSLhTsr796d9J+hTigSVOx1v9aBm7zGPT5akTiB5kShLG+qbOjbCQHqeHjxc1wPokaMJhVirAT", - "wagPC+e06sZkWO9ehjAC9/0yDrNPoVBFITcGVG9Cf8fyWiDpBPnXmeBWgAMYCD8s49EJ/udtl80+VtEk", - "ci5N4/nqVUjpvSfZjd/f1fL4D30lWzZ0pSSmwWXijNAONhKpsXAoURc5cQe1m5nvhauxDN3nFt1MZ9Ss", - "/XDVjlMRB3EX0/y9cLXbfHKP0NyElu7EQ/LatsrLjXRI96QoC3RLu/m4NE1+ZA+rLO8Cy09t+cLOHAE7", - "Sltl72RJgbrh4krMVpjqUAofOwIVCWCWK6ULEU4Er5xKXJsKYcRANdFAYM0iUBXkRkyEwvjBIt9El1kh", - "Bsp3ppkzgnFX3kiNpeuPjBCpsFdO531txge3/j+50U4f3D55gv/IMy7VAX4sFaP+BLcMqi+caKWNrdaw", - "UIJRGK9lhSWwjISmAmBRLMUjcZl02nh5SCQm96Qv8xwp26oLLChIy9fksaAbUY26gVzehWZUinRbjd05", - "vxJn1WLee3FrF6DYvtAiLt3UoEzuIEfowLKlWE82hButhgyihb2r7ADW3j3oikfgDlYuUMj823W9dZa1", - "m0FEoWPXhNSGSKAH2luHgB7nf3MVR7RirOsubS1iWuPyIV+1BgOH4VepWKbHABLnZHJl2Z7SjiAKCbSm", - "FLGYHw1oG9fczJ4zV0C8cwplZVXgUSggA1CScih49x9Q6QDDjqLAlHfSrQGnUv0TXMHUgsN78Rvgr5cN", - "7GOaFsTjsHIqIB0EY3oZCuUw0tPrGZEL7thPrNfDCrRDhhc6eGrAK53LJht7FsDg7kk/K/CE29pXEq+v", - "JNiGnSndEVwe7rz7fpceZSh0bzGvVJ56Tws3X/26U7AHSy6/mo3Rjw2DOzstExXOt1vFkpEs3P4x/x+s", - "zZ/FzGJgnyKEnHqps1SwfYdSKQC3h7noD9Sp0SOZeeOptBLT3M0qyVzxJ7qYpSyPeIFo8S47Xh9ap/MD", - "6BXC/QwUAn2XQEx9RuR0gLaEfQbyvcJk+BN1DfC6ZlCXAwW+A3UZW77INE8voFJHjGd/zaG85yJJ80vA", - "3XHOdxpLel6+Og337oBo4A3kXF4JlcaX103AdOAnpXFOtJmblj57HVM2epSyUW1Cpb4XA1XpxggLRKEs", - "NYOqV99xP5XhutL3haBaoPY5LBjUBv9WCDPzMsCnwgFD9UCFwtfhjOnM+8NY5h/q9Y2AzL86rhI2Fbax", - "/kBFSbuMi3LJUmlz2kE4GwrremI00qaW2IJJL3Wpw+gQJaOUs0vVighYzseCIZzKC78UXoEslRGZKcmq", - "0+wyHDYuiWCXKxi/YzNdsFQPlN+nlRBpnx07lgnuzzQqXK9gJpx/HGo/h3HNIwjZPHCDdf5gYQqvO/4g", - "JC3e7j6rCEK5xl2YI4iYkaLARkxSFNIocO0iiBAiKAyUM1zZcKR5xuSIcbjWNGX2pO8NyIxvlZvMOzKl", - "FWQAqiZGI5G4gPw15aDzeIbDOvZElElQUJ369PaW7npzo3M+9i4UGASvTVg6pL3jYYWXNCfYZZmo8R+X", - "CGZ0QAO/hLtsKs6OeH4kYT1n5HgsvOs7UDizaLmiXaobrcb8z2BjXkZ72e1EDbBQvtOS6FDV/2AzSIw0", - "oJayS+roZSBa9B4XK5Q/hVKS6IK2gPxGW+VNOt11l2LRZ5dV00R2yaJhqloDbZjI5Fh6MW3MflMpWArb", - "bCoQepxcTagZ7DzrgI0IVYXPOi22E4CSwj5eMmuVqYWRqST+Ug6pqcj4406ZMHMF8ZDrelHN2Z5LYDp/", - "0/szwTLVE3LZlOfs//2f/4vG04opV04mQEh4enz+8ge2mBLezB9IT1201AdUeoBpquzy8wBz9wedZ9Xy", - "gI9fLtfsEG4qTb0hZVunG1NvscHDb45YLHIWX7I9wCw/QMTyA+GSfoBNRO7OgNKwqNaIU4F2L/Dyyogd", - "RHYnegklfF89F7fE6GTamys65xGNWwMdSDWBtU35P8ncwpVD6D2UcCYFVOJXdBWQi3EYJdzI0sS6/Rov", - "Z110Q5FzFZvwuPcr73067P2lf9H7+PlJ9+l33zUjLX+S+YXfONYLJdTLRuK7pPlNBVXziOK0T13QPrU4", - "m46b/ifrwkZWtXboUFiY3staSiBUUBDYHW2EZApsn9EmE9OHEfMZyEeFckZWs+HpZfBSDwJnZzgJW5H5", - "9/fAhmL+OLuk3PWD01j3bS/3Ecbw0s9bflGqxCXSiYARxeWmDKswWEjOJWJc610LeODG8DwXhlX6U0MS", - "alsu4pNoLrT88P5tvCwm50rMuVZihacUHKUuywCPwCtVwlHXHHt6ePRn5CzqlqrnFzCBChZ0FcFG0AJg", - "L4aZaOGYrM/lkqNLidoUZhCuCst3EUDUyByTH+ZkMkrFnvdcIjQ/lUsCz6y4RY1cCfn5VV1Y1z1mtJfP", - "y2B/lIKIAVI7/vZ3Of8eHf5l9Xu+g5lMFk7Nd5N8M+/ThVN26zyJcHRCWx4LlVKWTzhMcfWAfoznYzi8", - "lGdjCInRmbnu9edZYRfmHm8318oZrezPsbisoYqJ9t37uoxY3Np/b5mn1gNS3+JyfqCsDJqw+jI8mEzv", - "XLLTPJw1hWdkDxIjuBMXkZcaBKloSnOEByOS/n3lOtZb2UiYniwD/sdxfkWRPBwp41AunVamdd2VQ1z7", - "NVbuFTx43yuHrZxyN9k51SUuGg4x3U07j1a/95N2b3Sh0jvMkYGeM77LygZ/fMmivkG3++teT6CG+QMs", - "JZ1x1l5FYqDwGnrxSQLk/li4JlIOVxgAcPv15JTFU0vltBMOMREkvSR6CeLVX0xto/ZfSfOrzFeFriIf", - "TvwiestOx1OJd2LCoNpiPpQEVpeSasBnJYXOx42cBJrXne7B/ayHMUauARC96gQ/RsmlxaqaIX9uQUEL", - "R+9tJdq6dA2RDuf4PcdN5TA/DSkn4FP7b+0vlfyBWiL67FfrUqZHI2Ess3Ks5EgmHLA5CdI4NEi++ECl", - "ovqT/zc3eJr9JHMKHvFkIsW178lQuPmvgKI1p5RW9M7P0WNRvO4CEElluJAX1Wc/yPFEGPw/G4CjmZ0C", - "zGUZWhkWjjl+JVim1ViY/kD1cCWse8b+7VcbP8GedBnBIfqFFSnb+/e3h4e97w4P2bsXB3bfv0gh4vqL", - "33bZkGdcJd6l828ewAqwvX8/+a7yLi5c/dX/7Ib1DK98d9j7c+2lhW4+6cKv8Y2nh72j+EbLilSk5QI+", - "0xL4Dv8qA980VQDMF/6GXYZ/WNcaBV/fbpL27mQ4z+didP9FjOdcaHIDAwrhpQDQRIazbjy8rwQUtuta", - "DbAVNPFgQLWpOwVfwy69mecZ56BB5MCXlAqFdeeD+4MI1vfCVUfA+BATABZWbwPByqR1cF6wrZL1Vlog", - "kLRbbkiPU5bKUTcIU3nQzBBO4xFKkx8g3lNgqfc20jPV1+0HzXf6Gk6B95j3fxeHTMizL4M7j3AlYQRw", - "wQ/3grsZBACVDQGERnvwXvCUwgfrmQPoTnBN/fe/FougEydcD1lNdvZpYINprLV9ZOIElb21K9ANxMcK", - "3E4uKpS4rRZikZn4/gpBWyiQtwbqqjD+UtnmI1zqM+EWjUWVzfgA2JLtBMJA68oA3ky3p4gCqJqtXGAT", - "yog2ZTYWbkxU7WTEVJMdwZLkfgvcTXBT7iyrJ3pGLakTqbDuYgVPtH9GKrq0IytIIK7keq/DEN3tbJtl", - "QdHHsqur0ywavrAtvOeT5rBuBf7qkZvLBqCfEYnhZgoTQr1L8a84hJkwU7OCYicprS8kJ8wFvOYksE19", - "MNp7Z8qzqXKkVbLtCohXmd2i19OUO8pJWqYxW4r+rzKv477RMP8wasCrWGxzIrqFRlCwaYVKbBoqbtOc", - "gVqtOqtDxrUI8UDNhYjbsdoo5ntn6teaIXcOkPT1CFzYhtbICXswtW7O4Gpj9/pp/SQuQsCnvvk+7wEf", - "nBenXg+e6ZXv7fc3I90ro333YFCOaQ7/4EZlXly3Niw38wB5cycSx417Y3+Bp+7pLFJpYvMslS0R42HY", - "jcQRH5T8rRBNNBGl3t7QdKyVrThP5OqSCbtr2OIHEkccTDWsT8CBaryRvwfzefA5LMoX4s4UiHk1L5E6", - "LwVyLuACQRSKmlAMJa70sjjK6rDJURMnLy4lJsM/8qX004pyDRnUW4XK5pfxoOT2bQycnUGg6Y19fU1B", - "ld9tNeeDYE7cOuxtY/Rr1R3LGRzCidS/AR6gJNfXo8qpnaq5O93ORPAURv258797Z2evewRo1ztv5Ll+", - "J1LJiQR0BOz1wOtNxeF784Zwv3ZfGu5GF8xlw1Xol8coyDDRC7NMCFnBdK8t00auSiADnLh1AsCvKk4g", - "XwgG/475CD+XTLqZYFOdCranE8czhu90GRAf/OnoaL9O3f6no6O2bk6RgbSxW/887P3nx8/fdo+aSmaW", - "V53dYXh6y8hMRCl87Js1hNj8/hzyZTdJw8v02B6UU998MarHFtWvxZbPiQzxIS6T7WCsSAlKGPFGjtTm", - "ZkY6y/RNc85Ijeaywis4LwixuB1qpeSIYd+ZtAGtbYnqtu9Mm7RTGXtza+UDF8RT1nmwXfGtHq+5HXrB", - "+qp3wKbdxXca66vPzl6vq0J5xmc3BsszEW55DWByM5TOcDNjp/FtlniDDXfUIyNsgJOmImwoQeNjLpV1", - "NXY7UyjA0ldasUwnPJto65795enTp1jbDl+dcMs4mDlv7r/J+Vh802Xf0Hf9P+lz32AN2jc3YjhN8m8G", - "KjCm2374sZ9J6wCBfm//GwLpt9Uu9W5kKhjSzAG7IrOK53aiXRdzC8OHgPNV+E/t+cfei1EXcdL/1mWf", - "GVEEn4nkb+zL/jfIEAusJkQPW2WGZW5idDGeUHH91A/QMjtTycRopQsb+nN8etIP/6btqUR+8B+5YZz+", - "XjXwEFsbKCRkfqlT0YWJ7bKSsvmE8PmHOiWOeGKNN8DIHXKQYD3KpQUQfz+FIh0oAoMI+tsfqIHCxfLP", - "Df3GAWmPVD6PjItGlKByWDvYZyeKLpt6RH5IdVZQDQyQ8DhRI23E2OB3+ZAKi+PlVJ1AEYsPdS5UN9Qo", - "YtM2ojpgjwYqL1kXA2S9FVnglIffpWWFivHHfimLwB8/EewFNv6SRB8qPvWNoo7HzwF1JjsZUfs9nBFw", - "wjALNRFEswfRlbFwdJDtVhahrOOH0ChIWIU/BqtUg4r0qacg/k5TVwDCD7saMDTErbdD0mWzpmAomYJS", - "/1+ip3UfsZiFth6oBq2hH0BW31hcGNXkawTUL4cAaEpn0HPU7QYjvSbsEG3vsI20x+5O8Snfk3uDTowt", - "PJCg1HrQJiIlYYahZ74KpoVET6cAoVRuPtna4ZYgAjbnN2qlDJzBU/cqBNDEw0oBdaFNDODPD4wbt7j6", - "fKfl/0z/gHDblazDMzaKwo8ScP5Wh9rKLy89ocWjd1HIdJfT/VZL7kfzVYLZ//zjo0x/8uZIjhXPEEwp", - "nCK3l0nEk1kple/xsT+MXOJ4/lsy7y4HE2CJODs9/0dviBhbdyGeeE5rjWiFjQWf+r2l8553SxxU00ZJ", - "f3mUBSG0AMyGNdtFOFK5hm8FT/1hLBcM54H9OOxCmx/3YgY8dhiVf7SB+HJ/ZZYkaCdJ1YVbFZ8vp1cX", - "bmmg/oFs2g4B5zg2/9qaoecw/7pweeEgApXJkUDU2/++m723u9mK3OvCbRxHNyIB1PjxQZkj0myhEXDi", - "fXj+XvE9YiurOQjmK/zpxYdD9ngg4KWIB5IbcS3h/MtwcUXKrmUq9EZXlBW5oIrjVksYSpKrorH06v6k", - "TAeLtdlh2QI0mdMRW6DLuGU5h2Rbp1mla5D5RfFzPfVbGBEFlMDO89+VNn63HSIWLG7z5TvvfTru/XrY", - "+0vv4//8H1vZZViLg2l+tHNRWCnstLI16xr/2nsjlbQTkfaOGy7HzuVUWMenuV8LwH6sL8iIXu6z7wtu", - "uHICl2Eo2Ps3L7/99tu/9Jffyta6coa5elv1hPL8tu2I78rTw6fLbAbArsosYxIgq8dGWNtlOdC6MWdm", - "GGVGTOr6dL8HbToe+T8ski0U4zEiDwC7HJB/S8WQ26dKTm9mqD3lIGIm8JOGTOAvjxi+AMkeLKiogAT3", - "OzFWmcStq7XWHBfbr9qOrnes2Vq2m4XWEDdgoRBqQaPfEgOVib28s2JsnmWVz248sVNurtpv2HGclnHm", - "LWjKCNddoaxTBnx5gRo/Cxj1I6kAtRVlgpsrYQIHzb/wulGG0glyLt+dHvk9IZnw3AkT3lksPHrHzdV9", - "Oyy1Nu4x5XqDPrSd9d7BPEVF+y/jGh2naZRMlBW6z5eqF8x8KZOb6wbiwS9P+79vMaw3stRtfrJsC6RN", - "9hEij8IMRKKuqo35WWUz4gkIw8yFYSevWMIVslONpXXCIDQ0B6vV30YOdL5MDHR+/1JQaWP7sxOl4T8s", - "KZTTed0BXHdBbMIz4fQnYfRBKi0fZsuZgTGY4Jv6+zuE3PZfAKg3zfxXul5AuEkziG+M2A/n56fMGT4a", - "yYT5M4Xrs5c8ywI63PHpCfIgSes/eeM9yht+JZh0bCgSXljBPih5ZfjI4V954fSUB6Y3eBbJLiMJUKi7", - "/fu7RnA3HOaZH/m5/lUY3Vmn6AKe7znd86NkNFfpnSzfSSqmuXbo2tGXYV5FmNXKFPW3WVqhlq/se2Gd", - "NsISLDw2HgcbyUvKXnS9j6Rv4CAA813vLvr+cC6RaSZwyfHdeFj5+zumNMHLAQmPpRPKRGQp435hG7OS", - "1O6rh9NxD4uHH9597eIjK+EZq/TC8a06lHSfhYePDo+YHFWeQzahkiagkQb1e+HOY3/uMQgfGzlz3DXe", - "IJ43D3BbJ2uRq7nl+2usWrfEbp8zmtwQ4SKik+CStS4V7L/UghS2kqLHrMBjQonPh5mc3v3HjMH0eQjt", - "VD9RknhJE2XFCuekGtuNhIOd4VtMXItq173Mh1mB2mLUr2dsxDMrWJIJbmwAA62Mtolz189iXdzufuun", - "zM3YTBVy/ve7dNpa3h8xzg1B3u+maEUTB6xwKzQryPnTwyd1Ob/hKOiVYHAp889j/vjTw0P/nnT+Ba8K", - "mUhCgrTOXU+qZ4yXLsiEO9ID//WqPu7xOSIJzP5V2k0w+ooOjCkE0AaSrgX1Cp7HfqtaPQ8Z1EzGvYm2", - "3c0M/2nhHk4Tv3rNu8ugxPYdsuJhs0rPdts2a85OpWy32U09gSAXFlj4R8tgV9kFvGXtsjGnigMAuKDU", - "/7mOVo3CIWohPG2tHCuRMqGuRaZzUTqt1KxlPA13KE8Pjxr+PpIZHpL3lA7Nh3sVKuuHZ7+xpWpLW2o3", - "qP7R4aH3Hq95JtMaBWiztg4zacu9E++i7yllA9uCJh4oZaMcJy1SYwI2LEeOvfXGPK5owk1gAyvXG/mx", - "E9FH/W44R+AHeZKIHMSrcOVKL5e157jHhK7swMFUp9nHD66hEpur40JWx3wxrwB8eCCmrCc4lG2jSvfZ", - "a55M2MjwKZZ6UWnPlF3K9Bn7bMVvXwYDlXLHn7HPYZF6XiL874OBuvQ7Lq4OsYJF0vNEWNubaqWdVjKB", - "bIpcGAuB/MRoa+dMJsFEPGecveXW9WBNeyevMJ4BHLHkCfgXVbnLgx4Sg6ctpiGEgcPus1dG59gpzGRF", - "kRjz3Aa3/VKml1h4BYytFLER8lqk+DdpEbfMTbhiTxifCJ6Ge9/M99UKoeDRbkjsuBHGmxIJwX8YAZR1", - "FKORMH32MpPwlJ3oIkuZM8AEuvA1uEIWTiQO+ttnb6C+rxy+DT7K3JQhl2hstjxd0FL5xYDSUisE0ORg", - "r5/DHTW7/JsRecZnf+VZdokoQLXP6SwFyHY4wHh7TBJuneAplq7dSD/fE56LUJY1FkoYmbDLuiW87LM3", - "2kTPi2ZP0HGJdPdHICHE6jy25x+fAUWtlzakvucs1UkxFcq/delmubjc79bM+SWyF3qZ02YaQeBKak3y", - "ef4DuvUKHkaj1i2LxoYz+ngjZz4IXH14KzGh33uRDbyA4CDauj6V5LZWqJQdNqxHWN5ANL+uTnaZ1XXF", - "uuZZgXWHU+HVzBiRAHIXNsUdXov12Tm/Eta/l4gUGoKknUuUm0vceIfaTaqc3NCcN0i8cLpnBIlx2Vwm", - "uAIiYRAkvETs4Sf9Ck2kBej1khcAb6/LpIeaEmxWaH0Kgr+JwPfZe2CwAJVmibcn3LEnh0+PnhNpNwkz", - "r1gCqO8pzIgnAiHvR9JYh8o+hjp8Q1am30p/gDPSnCeWZdsxGOyQabfWjv92jc3o0VV9z4/Ar+iZMNfC", - "9M68PkYLsNYGj7XNB1itvMzZxmpmsG1QzpzhfXSlpjmCVaEF1Tq7MGLUZz9p1fPKZ4uhtympSDJukHzT", - "92Og/KOhrjjaJCFN4NIFbdZ5kXEXYAO5YpxY4C8KK8wFIcRhvnWf/ewmcFmpMztQkDyCwCtOmKkkLu4i", - "c11iZQVjju5BSdkIdbrVAJuT3mAAuj90siymHqihGEtF7MXA/W3KcuPCJXoqLgp1pfQNkkcqgU+46HdC", - "5XntOqPJ2uM6YMH3udbZPbn02AA2VnPpFyELDb9B1uhaGFJalsmpdJhp9IS9ky+QGvOI/ShfeL2Jzre3", - "4eDaUDpPKhIN14+d3/MEUY4YX19S6hu4pqPjjwT54U1EwZMggKXUcuDLr6gBOLzalPXwC8LZfxwhPf/W", - "0b2twxs84zYtRPkQG4oxV104gUlnMTEm6B1LwNv1B66hYHoI2umPXPWbG5B1gByziRdJkVYxIdYsiSZr", - "CnZnDTKtADTh9UGhRay0aRm/5jKDgDoZp0qRNnq88QuBXbq0pvFmfjijsKHOhQIMBsB9mDExHYo0FWlA", - "WgXTCqcTbQEawQKkw8+KILENeCMgZ93gHYPP4g8qSjptwimo7CMfWmgugDKAO+cNOs+M4OlsoKBXmLUE", - "QyBcCt98N7yGPy30178qUkwrRLJvg0VZ83b0HKfTIGj5NfDJU5DGz4c25bfxWArdrMyhm4gpSzJt/QZg", - "Iqey9bvQDGYJzrRgxCPRN1dM5/y3QsTNkJjAw0ZpQVpb9tMu45lWY4LyWGSYcXDg8dPT9YPAKYrMw03j", - "yY2+lv436RAjAn4NfQMsCfQD4oaEd7XelU81aJCi3NEpV/5UFA5QqRgW47Ff/eop0Z8eiqEX/aH/3EDV", - "z1st14vl5mY7927woZml7Np0ymnCh+k/ZD4omak5g7FgJtYxXF++/P8AAAD///Yizh7NdAMA", + "H4sIAAAAAAAC/+z9i3IbOZIwCr8Kfv5fREvnK9F29/TsjBwTcdySva1ty9ax1NPz7bgPCVYlSYxQQA2A", + "EkVPOGIfYp9wn+QEEkBdSBRZ1MXdthnR0RZJIHFLZCby+q9BKvNCChBGD47/NVCgCyk04IcfaPYO/lmC", + "Ni+Vksp+lUphQBj7Jy0KzlJqmBRP/qGlsN/pdA45tX/9LwXTwfHg//+khv/E/aqfOGgfP35MBhnoVLHC", + "Ahkc2wGJH3HwMRmcSDHlLP1Uo4fh7NBnwoASlH+iocNw5BLUDSjiGyaDN9K8kqXIPtE83khDcLyB/c03", + "d6hg0vmJzIvSgHqR2ubhoOxMsozZryi/ULIAZZhFoCnlGlZHeEEmFhSRU5J6cIQiPE2MJHALaWmAaAtc", + "GEY5Xw4HyaBowP3XwHewf7ahv1UZKMgIZ9rYIdYhD8lL/INJQbSRhSZSEDMHMmVKGwJ2Z+yAzECut+1j", + "e0PseeVMnLmez5KBWRYwOB5QpegSN1TBP0umIBsc/71aw69VOzn5Bzjs+0HJhQb1omAnlPOXN/7AV3cy", + "pZwTM6eGZIrdgMZ1TFzfhMypyDhkZLLE769BCeBHLKcz0Ee0YEQjrh1X53BkcUtJHnYtIRecLheKzeaG", + "pDIDv4dMioToVAEIPZdGEyoyknJWTCRVGaFpCloPiZ26dtPLqaAzwGn89ZwwoQ3QjEDODBkXnJqpVPmI", + "FmxkVzQevhdrJ55SAzOplvZvEGVud9BPt7GD2igmZnYHM2q23oLILp/abhbzZalS6AkAe166Hh+TgVGl", + "sNPN1o/sSpVA2BQ3ws6QTBnwjCyoJlUvkpVg8VWzD0A4y5nRFh/9CidScqCIaiaC/zgVYlgO2tC8IEyQ", + "nwW7JTlLldSQSpEhNLvh1AyOB0yYP/6hBs+EgRkg5XHf1Lsdjiey3SuYbXQAmNTnVu1pT3w/9Qe4A2m5", + "sChsr0RBl1zSjEylIuMKrQhYuHqdmljUXt9Kd6BEl5OcGXsuRpKxJyL1vTiRGYwTktKigIxQQ/707M/f", + "ksnSgCacXYMdVC2JNHNQtpUpLXlyGzckL0LHG8otZmiSlsYSJErSOVU0tdRxYukxVUu8ZiAybU91PBwO", + "/17hzK/jIXkx0fbs7ZqbY9qFIotoIFHjmpTux1EeQaZfKOdHKZfpNQntLE21yOtoi7IzyRnnrIFafgxR", + "5hOHSNUMRixyJc4tN4CMKFka+EbX802IoLndU0fWHLHC7zRhRldTOIDhbEjGV/QaLiuaNE7I+GX0rA6j", + "+6AcL4vO0KKV/52wzDKlKQNFpkrmHYQ1tM5ZlnFYUAXRQbWhpozs+49XVxckCGLEtUL6O4xc1JW711jI", + "ys5X47VPfcN1tHfx0tD0en2KJ6cX5F0pLKEZYpMrRVMgCgoFFg2ZmOHe/Ae9oZfYzzErbdvaa2J/tL2R", + "SQt3NYfklSWHmpQaiB1B0NwCSqWwPyMjVxSx2sypIFrQaxilVCO9zFGssHBP5krmQE7h5kpKrsmFkkam", + "kpMFU0Ac6YvzGM5fKYtg2wULXM0UGyfEoq7KpTZOiGiJD6ukhpe5eOPuxtog/wlKHk2ohoy4hsTdIrJg", + "Zs6cmMKZiOJBMpiWAvn2G5pHyFnjJEJDvEwJsQQjL8zSUyWkIFRIscxlqavGOorCdjY9VmObRdbiWsdX", + "4347y+K45z43rmN0dqXi691/fvfaLtmuPVAzD23KeOyirtyw1jY35umGa21J0j7v2FVri4grHG0NCQvH", + "CQmnE+B4UDh9vFQGb6CjhlQvRUpSWmqI07uCqvCI4PztdHD8916STk0RPv66xn0RZGsyiEk4FfxWD9c2", + "s3HlNhKiwqRzejKnnIOYwdlpbG/oPy0LrQm0nlPlxF/H+B3FlgLIDdNswsHyWAdwSF4IggT8aKZYhow6", + "nVOSUkEUGLVErCWUTBXoOTFUX5PCPlyMsVcnIVoi4HEFccSyMcnpkkyAUK1lylC2QzB5yQ0rOJCxBYQt", + "kf9rKxAUSmZlCgo7IzVVN0CYIdRKcJpQUlhRWgFKOIs5CCdi+y+YJgVVpsJsi+XVpAgUTFs+Qs4MySRo", + "IqQhTGT2FQluWdISOksGpGp0tCjBAcUIJJxrCNVxUu9Al9x0PmCqgwj7XQ2ogKJYQIklrcwivSxNKnP7", + "gPMCmRQp4DE0zvEXyoy/GEy7g09aTIO6ZSqcWGIZRwZuCPc+kTegLG2vp1KNO/bTHGnJb2CkDVUGsjHK", + "ZSu/OfBj4hY8AdzkG5aVlBNsoXAa7ulkT1qmaalqzEAJPpBKi1n1Evs+jtx87vs42nCg+7dS51spIENN", + "DhxC3Pft5I9uB1K5el53eFFVF6H1rOpa4oZnlu8RNqs/9l3ZHh+TQZO63hF/z07v9OyphP16N+zDjSKl", + "NBIfJKuEKiGpJSa2hXuQ+YvvmYbFtwmYBUCbptYPp673wdXKQIHYR9jaGMfMxsfYADmHnCBLyVaWk3Kg", + "itAp6uNWpjokryxFldcgAqnWjsDmQIXlSdVrxTWyl9S/BJ4jutgP+oiK7GgueWbZmevZngPOT9AbNsPL", + "TRd0OSTjKWW8VBDWEPijNtK9ud2UBYHbgrOUmfog/Co8ABR0b+e01Ba4PTG/PE0Wc8ZhZTIKcsoEZEMy", + "tjRClmZlBt/ouvURhxvgZGG5z6TMZmDsdOyl3gabTqjIpMAzckfjMA5E5uUFWZpwIhnRbIYnv3nFFQai", + "NuCWWuZNJoACIU5lwXCO9pwypnOmrZTuxUk8hVLYqw7Z8L0IOFQxs5VjS1CKQBUqlwtClSyF3V8rzWhm", + "AKUobRjnRIElV4RaeYdlDlkS5IH4p/abhvIU4hdzIkqhJOI4NWQxpwaQf7Z21K5kVlKV2eumyzQFcLMf", + "JBVVduuwFN0hhL1z7mQHyaA6h3UanQxujyyMoxuqhHse/n1QUZTLALX65lUFvvrqqhqn+upFPeDHZLCA", + "id2r0VzqiKT0o9SVSFcETeoqKUKJKCrtB+AFNfOIfoOaeU/g5P8pkZC5hyLcpry0u7z1zdQi/Q1FRIug", + "76CXQGi48VsEzCBY4tFXlHeDcPkbyFar69jLVVvlqqaQ/Yj66E0HtLsgFbAvKka1nw1frAjl7iG+nCx+", + "rb/trjzVt9hJSzOXihlqmO2EXQ3LmZg9J5lExmDfqjcQkdNmtLAD0M5Hm38aLuZSA8mAMxTRLCtypsfU", + "UjiqAIexnIUKA4GjrIlqaDYcdRkSLE8+0gWkbMpSZ2J09jQ7Yy+ceDX2y3fv3r4bnby4uDr58cXo5zeX", + "b1//9cUPr1+ODysNvxSOwWm9k1b5am2vxx7M+DjIDQpMqQRSxlJTK0tqydHg15LD1loLv6gDDUDG9WbY", + "WTekJ98vYxnuquvfVAimFsWsYNUQpFZkJNfEyhQpcL4itbRlR5Kz7AjHXJED3LJ3FwQsRXIam079UxAO", + "j6i2opoVNNc0UqgEsnNGlbXFsw4k3cjMe0oKizkoz9I9L7SU3olCDyEsbIT/0PLC/QQES7eb5HBds+xX", + "cM1ERhQU0p5CsJ875B2SCyVvWNa80KhQcoYpRzBUTjn7YM9eGC/IajDHBIQBVSimgdxQxagw2u6lAn/f", + "SSo5p4WG0BGYIjegtCVtkzK9BkMObr4lT8jNd4cJGeOrakRFNrKvqrF7b+rVx1ZF8rUTtUvBGT6B7DLd", + "lKu1Uk3GaDcct6/MPIg89pzC6dx82/74nT3XUgkr69tjmwEY0Aa5WHOig2SAY0QvWOQIL9116CfkKfQ/", + "KIwT76i+/s1Euua09zJdT5nOk75PpiiLn9Tuwp0zCXRLdm25Q9dKbNTgr+Cu9qib1CruhtLYvavFjEc0", + "PvjW9jyI5FQsnZoZH9SpFLrMLYHIS22QA1Ntv0EFs5+gm19bq25ZbEGZCnaUyZJQpdgN6iEySxN/sYvw", + "Cp+kYdZIpVLAqQHd0McHW0ol/MWF4MTr1VsWlZmSZaG97nyTPec0SHRegyCd/fbYbnVrpTn1qwGvSWJG", + "V9YB9LhgaDyoW0+A5Exr58BR7QOhVjpLkRUqmEoFEQtLWipnJAtisOnQ5/9uxPxPIu/4E+8wLd1b4GGa", + "TAD1Ql4qOXen6Kze9rK2TDKoZkqpUksipIP587vXXkBvGAl1OcFrrw8fR45am/ZDCFPbKeJeNPq9i0ZZ", + "8aI0cso4P48+OH+Zs3TuTkpOvWsZ9T2I/Z9FphMqpGAp5d727KhpBjdGSq6PCu8z839/mz199mf4t+/a", + "i06psnOlWWan33O2V4rNZqBOZJ5Tkd2B0V5SwQxizzjAHBoHdEyompW546312pgoSnMca87EhsUmpGBC", + "OIe+uTGFPn7yZMbMvJwMU5k/cQ5Gwb/oyRqcJxMuJ08CMJh892/PsmfZH//8p++zb/+Yfffn77N/+9Mk", + "+9P0T/TbbycUfbefeLfdUYAxtN8OyUunlvBrcxSDaZK6PSRz6qwvBs0Jlg0pyGiK4h6kDG8HE4SzSTXL", + "Qsnb5ROLfVZEepJmxajeuiXNeYwj+YMeoVw5SmUZk8Sdmwt6dLnmTgz15gI/44B/V3iDPWkQWcBCpBKo", + "3w/Oki2u0JAjPcAoc7qqB/xGk/+4fPvm6N3FCWGZtyzU07Hy0gTIPyTun6Msnv03valrXYdlF2jbcMoY", + "74ABJOUMObv9n5DC8/Ue8nAqhYC00z/yLLBTt40npxcET5DU/VoLcrJMRqRImvw2K0a+Q/BXyIpRxnT4", + "ckiuFtIvQqM7eXDhQxeSsA32eIylnBS9TJD+M+3WmtPb1yBmlts9+/ZPEYrgkGeDMDGh6TWIjAiZtTx5", + "PJ90jxpnBbJ47+90N4qgf96mAbFB04uMnHCGVkUjybNv/1R74ernhBIuxQxU7ayLIjRRYAlNDaPfZuRg", + "5jJrPoRWaVSUquae4u/mtrXCMdadt34Jm+o29NhKaCobE3Sgdpd5jHc23FMzh1wDv+m8svaxC1p3oTU6", + "8bnfmyfdxGQ8Yzc2HkjLxdkjpLe7+i56w/n1OpYVycmfUQNzN4lPWVEJUiIFfioXaEN9EHbnIQ/TFugt", + "TK+r05717cL6wuww8OrWbKRfnkr6pr8NYdnzxc+NL2b+Zo5m5QbkWueHoV/NEiuryG/KyOJ0J8rOvhwW", + "0T7EfnyCSw0Pyx4sxL5cwbXdM4NdmMGeuH5uxHUDebIX4IumSr2o0CWYExdTri/Zh4elR7oNuydlWuu1", + "p1F7GvUl06g5sNk8olALt4C4BhY7Ts8uurQd3ZRu5UJ9ITQvGSxYFrOtVNuGv2/ZtQUTmVxEl+23j7gm", + "a5LxlkjcSjKsR+hLj3/BHj/IUmT6oelxE3Z/etzutafHe3r8NdJjdwv6UWMO024It6SQGu+xheJSqZBU", + "SpUxQQ3oO5H45h39Yki8kUXnLi7vuIsdbMND/bRMo4KFziB30ey7aV9i94hi300DoYe8FJCRg7EzxY8T", + "Ms6ZYLnlE/iB3tYfpiXnblvR/djri3wMlEupUPm4ZDBlApVJHRbze/NHzwk73QADCoYsRhWhaZE8GWKH", + "LMb/ApNLib4DSKqOHesjM9CmVKCT4FCM6UgyRrmcubwjTMwSzBdANHBP3NB3vM6KNCQnUkzZLLimhzvh", + "wp3mQE7fnj/xOTSIUXQ6ZWk9V84miqL7ki6hylFV+UdPYE75tPJPD1s+fC/eCmj4cHXtiQ8Zd2EwDfbh", + "bDyhlTYKaB4UhBrDyLKEpJJy0Gngmd61CmPo3chME45uOYIva8bjDtz+GHxXkCwDhxxD7NPmdiUNhlXx", + "x4rX1u7/qcxcCgtsnnKqNZv6pGiWZ9pW1wAFKYsheWnH1ZhHxO4cmrHRFWWVywyrWY3CXMfPA8c1mztk", + "CuMSP3UWq5ULsnc47XY4RREDt+oTuJlGjiWaYSjcubYPaT3V4Dlq6ZDtmyOPwys0djdrPCQvaToP/lPo", + "wsaQdnDnplVfH1oUSt54IdH5u7lRjmv7Ln5Z8oygpxQlGlIFhvzPf/03sYvNXNY3i0ueBxu4NQn5+d1r", + "nRAFU1AKlE58chWdEAN5gb6gnnIW1MztchSdkTQ82ZBgB18lPxc7pHfF4jR1a6aEo9yQWJJp77H9w3mg", + "pkCmnM4SDEsRZY7OmejCh+gOOMZcap8DqLGdLsNiTovCosLxv9ZN8r3t7BFfp6TLMLIV6Fa7crKi1OwN", + "cdUCkXTqDPqC3KBOTDql1R2Ad72Nk8Hp2/PhVKZlD3CnMn9lW64D0KmSnJ8JI//KYHE2fYNxxL0gXka7", + "RoYA84pxOLOihv2j33wvV3u1AeP3AbEUnc0Qg7fBxV4nrU4xsBnThX2h2TZe/OoF+XS13ybgP8HyLrBD", + "t02gz2Wp4S7A646bwF/JMp3fBXzdMQYe8tKSS2z0Ssl851W87AQQG47lgHe3Iug9Rzlb7RcFLjQocwW3", + "fed+VnWIgdNLYeZgBYsLJtL5vzt5vSfoy2jnzcO4u33XcVq9Nw90RYu7jlJ3bQ9xQWcwnFjp5kq+UrIP", + "+tguPzR6RAB6X6865WI/qCer3TaAFrTYHbDvFAPbjzUisHW+iCBczsk6ld4pPgn7wfwx2jcySAhi7Qf2", + "jW+9AdCV/JFpI9XypTD27bEL2HbfyCCFYsJcyYvTV/0AX/j22TQCzCUx6wfoHaxLQQjEChcwec2mkC5T", + "Dk4z0gvkZaRnbABDlXEYnFLdEz8v252iYGXxWtKsF+d2IKsOHeB2n2SzTxvoFVUzMEOaGnZjEQM/bgfr", + "2r1o9YoCxuu5G9STukscpAJqIPRyTnm9YUf6bhhkx4k3+kSBWtFC6jtO/TTWOTqMLEAEU0xf6G8bfZpA", + "P1bKjqXLQBo0fB+TgRSwk06zhxD2MbkTsJi4uCOouGiyK5BNUtMd1xYXJ+8ILCqz7wir+2WxI6B+kuyO", + "QLfLgXcG2Cnw3RliXLjrD27bC3InSGtv593msfWd3B/cJkF1NygbBdO7gYqIorsB2i4y7gYvJiveDUK3", + "WLgbvHUhbrf+cWlyNxgbJLNdAXVJT7vDich1OyLh6hNmxzlsEYX7Q9smAO4KqUPo2xlMhwh2Nzjdstau", + "8LYKb7sC7JbX+sLZqn7eHdTdkXO7JvgusLpU1v1hbVD8f/w1Zg86r3w5thmyT04vgvHU295vl97kq53B", + "VoMJEe4Ni6kmVBA6AxGrD4RZGZ6vGl1P356jeSTYpSdSXl8DFGjvtj849606wP/ns8ZoCpNla5YBeh7V", + "HmXoHIBirj7uHWPeqcPt1BxHtI3d2s0NWtxuvXeHor2HynaL6nKbynGjprDLrtC0iWw0b8SUhJ16vg4t", + "3WYV2apua7OKqq0OWtc0dShi4pqPiHqlpRHcpDbqUntEtRZxlcBmbcSWF3/8pd5pSVy1A3Zb3LoNfUm/", + "QOQmPUMXiC6/HEHg1he3874tLpNKWmojc1Dk8vSnZqWxhFyURQEGQB0G38Ha4zHiteOcY5gmfz0f9vW4", + "8A6JD+J0Ua9+73SxxekCt+oxE7ZGzuMOGe9rh9WIN4b3Te0uJtbfS5bpqJtswx/WHvW4BXIcfMFiziHO", + "baP2rEU2/Nj+sh83nsdp5SPVn0DUflWQ1Sldt1x9ckGZqnNNsTyHjFEDHMuopJCt+xf7ncSNcL5uvwEB", + "WdmgPQ3ZSENq1HhcMhI7ld0pST3bdUrS8GnfE5MHKEnYoNp9qhLmoDWdwfZcRu71hY01UcDpErJQjGl9", + "XJ8tMGPKfRcvbqaA6liBr1/my1WYWAdiSMZu00cuavu4GcmBLyu8t+5LqWFIxmXhKNoonVMxw9TJ+HZj", + "ZY5OrC4HImZI9m7/wS/ZYZBxGQwXogpEcaM5n0AFAa/pjDKhXQyKgAUJ4zangAmhx8fVb+hKTaQK+0qK", + "Mi9cCmm3Vp9qo0pn4Bcc6iqG/BqtlAfkwCwL+9rky1AsUs9LY5dwuJK8rLGVg2SwulPNr3BOWMdtZUbx", + "5NCrTsCb8EqXRciiF3fS9hdwAa5o4YKqrJaDw1WblCYk3/G+z5AlrVIf/yyhdPUxpiXHXV/xki6oYOm1", + "3XgXMMVEqiD3ftw+KwS6cwcX1f/5r/8mpajnj1kyg0u3U1Q4ZcCUcYNVEycuISbNMHGmxzU/bXv/iJF2", + "eY6KGWkoH5KryjGc26smBV8+94V0mhEpobCIu5ft3RmSF3xBl1UtGiRiWKYzOMDbFRBmnrvsnc0GhYKM", + "VnUskUsmZIGZ9bw/fEX2qCYfQMmuAI91X/JNaFGfdHXhmtjg821mRMgY0W6ePFMNl3uf1xA91x2aDdOs", + "GIYZjfyxjVejFpql0+w5YFwaQ9QI7vOOyKOYNu5yg98SK9mk+aukuaKWWxh2xCx153jJSm2yJUKy0W4f", + "E7lTUiWX6W0kZAZ9MsKdvj1fywrXjE0zqCXZJwv8kvN21LrMGO/dhkn3xSBHczaNEHTmCnJpgLgOW4f7", + "dImavo6sJr0cDO7FGOI69B6coqvjnnXsWceedTw26+gwfe15yV14ibLz70qT8KPLj+D1MrZpa7ruIBwP", + "aSNvJssJpkdf09bgeJ1x+1mdS//hRrtdH+lvRE6nGnotLcE3KVYu87fdYsxuU1iuT+H/fLopfD3ywibH", + "yPuJCquQ+0gJ6332AsJeQNgLCA+ZiJ73UPzbVhrTfdQHghd1SF5V8es7FCzoEEvWvHb2EsnX/LptYecW", + "1hXc0uIle+zPIRXMnGq4Q82XkL+HaIMq6CmmUnDnFTz9UC2twSBFq/LX+NQrRWnQCVFNmMF8Nr56USji", + "E8zuLftMZtclDKhBgn+/vfF/ysJ/41xRexbLsftwznI4aVjyV6i0LPzZnp+dvyTBVoyVTFx+CmYgT2pX", + "hLMXb14QBTOmjVq2dOTN5EjPm72/0USXEztNX/TEmRW4K3Ze5eioxm4kUvqNjsE7oKE9ZJAMaJkxOUgG", + "NywD+y8tCu5NSMhfUFWfywzPJS+5YZYy1+r8nqflfRqdM0McsZFWEmfGJ5TULpFZyNxEvCOm/nxwHqa0", + "5Ha7jCzTOW5lqftu2pZoqzvLsDFn1y0ibLzLXoLdZ4z8ulROUT/xrzfZ+PbY0nsSqTVf/V50KtJrT6r2", + "pOqLrjaj6Gy0ywvY1+ZHSdZ7Md7xBYxDW2F4+9C2VcfQZwbyRsK43UbPWQ4jL2R7FF55NTFtmEgNMdFH", + "ASbUm9azcvN0EQ1jKzGPEzJGkdn+0ZCRx4dDcukeANqnzutewX1zrSYDnNcOTsirb6TaqZsqRZfV/tm7", + "j+vRo5zq60j+W2a8T7F9PXEuF5bk2K2qu5KDZ39JZbFMyLd/4UxcJ+TZH/+Syxs47Do7fMZW9ZB3TZHb", + "fiivJ8ldfSkfk3H1BrXHGB6h7m9ZuLp49UN0fP/kuN0ixHoQWkddQJQN9JYTmdt/Q2NyDUs8jBfc2LM4", + "MYon5A9/OQdDE/Knv1zO2dR0nsnnmE46YtT5K4MFugHeNnJHW8pzcnlJCnYLXPc2mSw3gF/eF3yXvqhx", + "N3aQwWKJUB5IBAugd5LA6k57AWynyrylkSMFBdBYuvk5eNVUSAptb90MhKXELpHqNWAsMVDTuueNKI8g", + "4m3l2WBRR8xqj1XPFl2K7Becjw99UtqqcjhOam9Y+VJky/ux6UACOrj0T7BcYdLXsDyVC2HZ8jUsfy7s", + "H4oufvJfI5e2LOJB+DPTo2tYFjTbfM3sfWJVwnZR5qBYSlzPrhvG9EgvtZWLr2HZ5xajY77rggOuX6MG", + "dC69lnYN8E+wnEiqMhKaWFmAwxSFAR+M+91fRJkXNDvsb9bqiM7/nYgtguaQxTfaYlgzG7a7kAWdQVvH", + "vywqfS4L4czjSlq8ohP7zwul5CKg56vvrfz/k513042f0FqEibwDnhMzlxpayeonS/fOGLn8123/+s+8", + "1ke9rE1spobbdnwpJzkzxteWJ64Ar9HAp2ha7PFEfFgBK56j7IFErBr4TkJWs9tezNrJqaQ0xpHQXbka", + "7vkPrvs6U3M/oEmwEorC2g7GVsyw5MPSZFeqJcs4foPU2f4xoek11mpxcUMPUagl8avdRpR9K0ecM7kQ", + "Q/JGiqMPoKTlf5SM0YZ1Lm8gG5McqHA31r70HQvzih0z7xQBOUuvt0ueGG7spDm3nxgZyDGsjRx86wfD", + "d5X7+nAvc34x+kzghsa8NX+Uin2QwlDunSIJNk1cCDai5i9zAD7u/bx3Q8Ue+ZZQpA830P3k6JrKd0jS", + "2GBFlsZpXjiGj6TGfn4HHGjzC3+XV9f1ACRnKn26g5U7jmWqkAUWdnKlgoQ8RRGj925uk1AbWZp+JzJq", + "Ie0HdWcMuHD9O47f/+ok0VoZUD8vDtzhunMuHqb612cqklIxs7OhfBTQbyOO1u0b6Hr0bDd8NYxH3c8v", + "LFNg3NR+SP/n6D9JwamAxIlzMwWgdxtn2Wecv91vnAXTJj4MpnJYMA1ESeNz0K2OsH499lrczSmfH+iR", + "UQPf6ZHR7LZ/ZOyN6XuFZ2eC1uqmdHBqbLAiqKF/IOYstlIZfnopsupvK6E5xo0fH9BA2V9CCxbyKVPa", + "EJwHQZHmwUW3RtLO34nopmjGSr3lPeIadW5T/ygxN9imF8mDDeX584iKGY+gwTv/+4azv4Po8JXKjBs2", + "8aGEySsr4HUN89gSZs/B7yl2WpAjBLldg4T5XaE5Db3CVNFu2ZnjJy7inqyJtztcj3vIvZ23nTiO0iiS", + "TbhdNxPo1+54Br4P9dgLFkxbtuDYBsupWvpTalpEaNgnf5mCqPPwIvc96VgP6TuGOD0k8n4lSu4pmXfn", + "l+4loW/qvpfUf+fmgIcQ4R5Anb5XnH+tivNHUpP/bpXiZAJ20z3RxNuEnOExnT03FBDYe33uvT7vrC/c", + "WG7unkLJWmmNXrJIpNdeBNkrC7/GIMH12jS/RlMuFZymmJh3BCKyU+/qBkRRMQMCIvPpgjq1VQ2gmBO6", + "D1hsuAWw8/m1hxqd62X4uTFHzN3PqtMMm7F9hI6J12M0J7zzKF+DH11kPx7aia45qT4MK1pk9b6cqgLa", + "j0U1mu950543fZW8qS6l9utX62bsNuERPIt3JIrbKxbfk0DGi9P1IpZdXfeEc084v2TC6TMOjVw6ojur", + "p9bTHq1rqFp5j3AVGzMfPZ6WqqOGZfzV4jKQjnQBEH20+Ayl3uWRYDs0hjk1CSnsl1jIp1NGTymH0ZSm", + "RqoNI2CzYEAq7MTJwfvy6dPv4Bn5ICWmGzj8oi3TX54ebCem2VmV/8G4ZmuEHdnmSt8939zzzT3f/FL5", + "Zru4c4xxFspp+KfcftEZE4w/u0JYZVE4dtIVCOzC/Hv4wlS00GcGqNzHnN1ugzLRws+A02W0et+p/YVM", + "wCwARICdrJXr+4JUcR1Sz+WCFeBEnZ0knUdm4LeWCBgqYm6eEaOwb9se7DlxKsSbgC2acJiaHeaAdeyx", + "a6RYbI2a83pC1Uyq5PcF9SUJIWzmby2/JIPlht1ds4z33duy2GH8njt7EybzIPv6AIJbXer+UaS2GvyO", + "Iluz415e28trX7S3kC/RGOHqoVZkYOzoJYOlYzEXvP1UFr3Z/NctGNYk5cvRt9Oit8uhJIYWqxH7hhaH", + "X08o4GbO2MqRFE1g8zlm/PeJnAbJAPM4DZJBncZpkAwsyvXMhN50r13bHufiN1nxsaW1h+1nsl2WRQ2S", + "gZWr7QXEpCB2z7AcV4IFcBz+LajKdtm4Dai15h752exWM7NByKsfEhuEz5jXIHzApAY9t+2CzuAH+/WV", + "fKXkAzn7W6DDSQPqFrk00n4vju7F0a/LX2HtEnzF2fbtXpw4j4TLVAEIPZcPSJrSVdB96FOk055I7USk", + "3AaOJrCUIhvdeDl0c75I34nArQEsZFZpcEL/zrSvnBWdJT4tSmMBJCmIa3NHARtHQcvs5kEwCaOz4DpD", + "724jdJQObY6ATe6zitvN8G+DA+o9hlhuHmJ5zyH2XPBzK2ToN2N39UhNhl85GBHtSG7vnBuCWBYE2tK0", + "g/E/CpiNEzIuxMxlmljApHiYHFH2BTHSpZrSmJY8RtgsRzb0GkQdNu77r9VlsxSvi9p1yBNrTCsqVMjC", + "sJx9gNFUqi5HlzB3EKnMLCZM6Y1UkHkrkLwBRTT7AF0T/GdJOTMxAiBztPtZtPWNQpqNp09dDjoutV76", + "k/yCzGv3lYwELR5HLvKAd5GKqi57mWj/cNuzrCjL8nekk2GFBjGelc9Nzh+GR21hFX4SX/vrk0sND0hZ", + "Lbhe9NQ13FPRPRX9CtVfiP1fOeX5kYqMw3/QG3qJazxllMvZw5GieRR+H9rU1XNPrHYqeJSmUGzRemW4", + "sw5rsbmbUMZ0zjZ5SO4J4RdCCONXrcPRVuaFGe0eFQjCgEIHTiMJJQ6OR707Rwp+OZQ6CTd1O8l+Q2/Y", + "jJoHlBeFh9iHLNdt94R4LzV+0W9vRXOILvJtQf9ZAsEGDcqygTI8J5RwKWag/IOauRe0xTx7w2oY9yLk", + "4XJ2BBdOQSl7QSRn6fIuSoV3HsSFg7CuVAgNiBvj0VLz1WtRYHF4i/a7nZYUd931d5wnfOrHfRoS0Gfp", + "YKiocFlL7p6JuwLR4SrqWZRdugKq8R9/lAEXKOegQjU4zgSWZbITwuTcCrikD1SeqVR8hGuI2Egu8fuq", + "RjVow4Sb98/vXofZIf/CAtUTWWIecUv37eQc8ugqO6/t1IE8jXP57tsHe74FWeBK/si0kWr5Uhi1fHjJ", + "oA1/FzlhtedeathLDV90jQOL5tFF+ntAsEVrOeGuIA/oXTh0wzX7opVajT3eTiAvFBPmSl5k04cjioWH", + "efqqDyFstt4Tvz3x+6LDnpguOF2O5kAzUKOplAZUt2xOiWuI83WNyQKUPR6RwQa527UdGcgLTg1sfwXQ", + "AD50qYLAOaszVfmfdn0FhKJwo0ymiNIjWRrOBGyaT2hLfFucEOQTyLIeIxk6m0E2KrLppjFcK3JA09TS", + "9gmHQ3Jx+gqHqsy9XWP5M9xlj/1pPsYec8tPUlps8XNCx0c7LKcss1uLhCj07XRnomrGxGgijZF5JDk0", + "fk9cK4L/pfMdSmt48BiAsgb8NUzNvUGruN/pO3Q1vS9wI4sI/ZXFPQDHZZmaU8ZVz3QGI8ypqvsgoy9E", + "LmYreNiBAwUtQHV68F7YXxu+uzsu2AHvcKt1sCuH2l1Bo/JilGo9wg3S7MOWO4K+ruggzD64vSm8AsQ7", + "1rmLVGxxscPTGk1oej1TshQbnPjqNmSmaDFnqXZkHkFs0K7EHZ0v8GCRQzgf5y+7PpKiQtsTzmV2t+qm", + "2fTKwzi3INa1NT/KBa6zZg1eEjoYv8M/X+gfqIY//sH5r4bvLjF67CF0NHfVfrxDLdHDCfZe69RDqA8t", + "9wL9XqD/kgV6NhNSwSil6XwLV3E3gkyWBXXkEZW86byTf9jmoDbZVyoR2TX9XVla3HLjehZcyZ1SijNh", + "CR1kxHXYO59fgvkFJq/ZFNJlyuHSPKjdW0eg96H+8X57XrDnBV+h81DsMnwx2Vg0ruYOUvf6lqzL3VUD", + "gsMQ6l6o5GA8VfIDCCduu1qbjydmh0X2IMaGKuOCu1KqHzASSLcB9yLBq1321HdPfb/w2ulqORJmPkK3", + "o/W1vkJvJE3zgvskL6BuaGf60fsGw9pb1xlb5ObSFQyLZK0QswcKMKK3nWq7c3rL8jL3nlq1+q4Z6h0x", + "tdLbLl1dG2Cls9sCr4NxtklYlGfeLY51/264NLJ4LWnGxOwh2VQFtB+LajTfs6c9e/oaHwf1HfjKA60s", + "RXoc2bkJty9d2kvOe9K0J03FFunrK6FOKwbCSDL7BaEk+J5kaCdktZnws0lt2TZmDpJB25LZN4elK70U", + "z/vpf3STr3eMCkeCXUbQzysXqKWAIHpuzkqASKS8VEd8CA1OsFhN9bPJLBvWM0gaH36ZgziVCzFTNLPb", + "JxWbMVH9YX8+UVLrt+F7S+vqD0ax1EQ/rvcshaZT+Fn1TcC69nbvUCOsPd3dhuqq+2dzRv8oYGZRWMx2", + "2qJG6pR13hhP9dXcItv989yiBUyKvjvVTjGzjkrxHXK+R77v50ML5ybve8uuqJqBeZEadkMNuE8PIvE7", + "UEPagrxF5u/os5f691L/1yX1Ry/CF1Q3wi5nkzuNa/FbeNF0FnSu5rz5heIpKJf6EYhpWoPtR0lbHfZk", + "dE9Gv0oy2rgFexr6+dBQBdRA+FYKA7cPS0wj8HtS1WjPPXndk9cvPWBSahhJMcrA0HS+LZGJcFmyNPE9", + "s3q7M5iUs5mdY6CtDiTo/mm8N1zGrnRgt8uRc/oecaZNr8gsPCzXidhOSO1TKaZstikG0A2mQd30SfwS", + "xlmBjWGI7hfPX77sdC+lYDegNOUjAWYh1fXIRYOOnDJye605106TmaLCUqwKIPEAiQPodja0NnPINfAb", + "0HhB+iRzu4vtosnVHuFp0IC7CxfbPw7uwr36hBLa6+RlMLzXuN1ZCJ+oIXTd4LADnpBuEvUCfa9o7m8R", + "fbLn6J9f/hc64TCagCWuLoWcxSAleTdW/2AbO8OLb+uyASCojBzMgWbcciop+PKwOylCWurNV0fAonl9", + "sMemLAtqZOhkc4aBSQDYrB/ilfzrN7U72UDcgfMXJjK5aHhunp5ddHk3zlmWgdiZdrhunVFq0aB9P61b", + "X2HcEUBn+WmUOd3BITTCPqLyloDFaIGDbzoVe8yuVb9UD5/lWz2WlcCfy/KO5/I5ZIpLBh2uyX7tlU/y", + "hpvicGN09+AW7N8V1uIn4mJams7fwvIJbnclZwJLAGGuv5ze1h+mJefuwH6rwHJ3+07d8+rxNCVZbIB+", + "QmZH1720uZO0uZcE95Lgb6M6j97fLzxlX+S69SHDbwsQgSo9JPWVDbj9iG67x57W7vXSXyXtal6Djixd", + "Anh0e0I3gk2IhYTOw5+Kg+4tkr8zi6Qs0/nLGxAm7kuNvzuPaVLMqYbPxlfQ2JljtLzdDlymyMKf5/IG", + "wt8nVKTQ26uwnXV9XWXfM+n6Z7ONnIlrT4exQIq7fKMJVfZTaeRoIuV1TtV1+KzLiYuOtsgrSsqb31Tu", + "+KG1kQXeV0/8bduc2SPzyX2SwTUsF1I1/ho1gfQ5tPU8EPF8djyeC+KzOSuXsQLL2BiGCN5rdxqKhPW0", + "m56RLaL6hM8oSEDl1OJYpfRwpLX6u1Z59No1KbTk8FIpqZB4rm/ci+aL1TYmYFtb+a4UKS1nc0PqYk8E", + "blPAriE65WXODLprY4nohSQZ04aJ1KBIo2WpUtBkwcycZGw6BWW3yIqDRM9pAXpI3pXCsByGfvwXF2cn", + "lvRk5MB/M3QzsgRJH1opKSstTLyOCVaCSqzYqhOUgLSh6fXIKJpCDbua9tVcyYUgB9Xaql+aoB1MzgQk", + "JJW8zEXilzIqFY+M84oBzzx/tJcxpRMOQex0PVHGoigFRMVeamAm1bIpRvn1R0848y+bPkq4VSzAV5EV", + "cnBiPaFgz0vXA1NN2i00sXrcV8pKC173aU96anfHlRMPvUhW4v5gWlHOcmb0MKpuNhErBU6F2IPVhuaF", + "fXb8LNgtyVmqpIZUWnGqn5QeqpqsbPkIUS6y8StyjNEBalKfYLWxm0Sa6Jnsql7FnkEn8DH512o9PzWL", + "7N4Lzqu7Xj3HSCpBpU4OdGvVQ3Lh3CbQkuReRG5h/rZ3XVy79cxAjmOvS6nuC6oUXbp3kr1fsaQV9nvi", + "ErIGo61rYOeinKK3pkeIXni7e098hSoMoxjiuH70ERB20XZyrCEhlC/oUpP3A8Sg94N77eLa5sUThb9m", + "An77jaoJ5PoMf373Ohgz/MymjHsGauYKFu05PsDEWmm5AqHuSzAp55e2F+KrvVvrYa5lTsWRApohpXcc", + "yrMi3dIwBCRZyJJnxCe9R1XDK1n96ljcwaFjcgkCmDKlTa2PaVxQ6q+oAxFhZYk3gsCU3SKzcvPLQWs6", + "g4SgAer94OfQE8nQMZlImb8fWNbf+O2ACSzAyDQcEvuY8I1v6wfhtBSosHg/aBmRumhmW4EZSOOva8Tx", + "tZz1Flq4nPnXXyU1cDlLqv1lYirrTwuqRELApMPD4W/AicPC9nx4Kx+OlxZ9YC7cOo/fFw/eiZVuYFWd", + "QraFkZAqD6+S5WxOSjFl3JVgRXLrtNBDMkY6MkYrqixdDSLSEpncJdSECW2AZs8J5ZzgO4WsckxtRWWg", + "ilgeNSSX4LSguoAUX1tIA0vOicWJKGF5JNr+Cgnv6vGsn85wK6kLGoPtJK+FRZ2vW0fhgmkRL12tsAkk", + "MZeCGfuCw+K5nNtdPQrc0x3PkKyon50aLnGpytz7pg6xJ1DIFN0EFnOWzh2rxpnINC1V5YvQRvzuipj2", + "lFfLYeIL0csubjJx+ac7D7SF2p0EOiFWoLDyBAGazpsJBGLjCHoz0vDPSE43KaRxmgS+JEykCqi2z/rG", + "dmn4ZwkiDSJZ4prZeaH+3k3AyMIrgBs9o5vQg3reQVsdbphXCddWh+h+bFAtB9xc0y2TA21QOKKWG+jG", + "OnVYKPqEHW4aMfCFHjfbGR1ckUl0z4mLoQo43FDLt6QzcCEqP3dOaLaB3ZnGmdi7gL+5q5MErVLd1ns2", + "+6u1lSg0Dqu5se0l1yi4gX01RYHdjJwXSt6AoBZJczAUpQN/ckuLze6ie32IIuCVPNXNX5eaIC6pXXgQ", + "R5assylLPeUQ9vp7R6gu3jTG7W1Sr0pFhVsdR5xrFvMJdqJKWNAQ7WTjY8/YSGVtugh+Q56NOa1WTVyH", + "ZHwNSgAf0YKNj8lP+IG8uDgjLtSAHFg6o268RdF9eVQndwkzJ2O4NSAsIoyP61Tufj7Vb0My5jKlfFQo", + "mYLW42Oil9pATvwXRJVC2BOjXIqZ10rW020pF9OsQOV0mL/9KQw0sLS1MVBU0g2o0o1sESFlGz4EbuaQ", + "wVIrdw+e+HvyxLGKs9PWeYe7sHK38PA33JgfjSl+xMJTunsRRpVrF+bHq6sLX7JKk5wW9nQXVGXoRnbE", + "PKbY2VvSJktDnCqXffBZav7q1M5oaF0Wnn94KY9MSkNyuiQTIFQs0bCNIlJL6llbzJkwoCgS7RPO0uut", + "j6USX0y2aZAkvC8huWG0RkKXc8MVFej1OmL1RO77Qoquaf9O6nwnNbZ+hCf7iK+l7rN54DeTBg6pkZFi", + "gCeXlyT8Sgpq5kHHjmu39JWjoNUhUswiipyr89fE0JnjSF5HtQLNHlhZFKBSqgPX+uHnq6u3bxLyIiGn", + "Z3/tkGGiwvxfGVbXQ22Ro37CdAycEKNYnncoA29jsGFRSGXI7VHtwtwCbteCRb1cFuIoki03AF7eHfAK", + "Ht4O7EhJfdruhDY+kxoo+BMstxK8a1hOJFXZ50Duwnr2xK4XsbuG5achda1zeWBCZxextoE/wdLbmSvp", + "8yePx25vHQF6aaeYkB9oeq0LmtpXe5wK3YGaBrqH+vk5zVzwT+0Udw3LUAtQ6w7q1J/a+siiTdT27M3F", + "z1cJuXr5t6sX715209xVcRDuQWAuUyU5vwRjOGRbSY3G1kS75p7ghHcTnZq6SRVuoo0sNEnnVMyYmCW/", + "b/K0vht7QtWLULlTH3nE+DQ0q+OwHph6WfI0uo1FPyGe3x5VmE6N93ihyjTsgLbVDLRF+j5iCY637Bxv", + "+dDjeX3MHeinG2ubOCpjm/eKCcrDZJtbODXexzSsIJCaPiuRsX1rDbV8kKFWcNljSHV0ftF+Qus7vJE0", + "v2Y3YMXQE6eq7KTInN0AuWGwqByyXIfaD9y+46clD7T7G01+gcm7q5NKh/MGruXhkPzo20nBl8/R1hkI", + "+lQqUgXaspzOQPc2JHo9631pc2w79iS5kyRbrBhZrAj+8o9IiTuPZkclLaijoLkv6BJLZlrEG6+tZdxQ", + "Pq8+pbstA6+ri7JuHxiSy5byXoEfSntnRyw1jNcr6L8nnBUYd2EvCboCeiUqOv9VIQfjekrjnZTlPTb8", + "tAp86E8d6mCJyotxBxJxQZmzXUVPZbJcW+5vQSJWtmVPJXpQiRotPgGhiB3Qg9OKRlRQJ7nISoX66lEe", + "Sw5BOT9KuUyvSWhXaYDqoCUmSM44Z42D2Km2+iaq9NyFPnmjdSqVAl1IkWEsVBdV3NEi19yCDUd37qzs", + "pw3q0UFzrnx4VzOwSwZLj70VXGozJFcoKxq1DGTTGwQyJTFApxSG8WDcH1X0GEJomB6SKwXUoAWBiaNC", + "yRlmrLJ3Gn01nFP8QUjtxDKOnh8zGHG6lKUJb5RDQjUphQLOkAW4kc0cRD8C5ud4X+rVtcN78tVJvgJ2", + "NHnaI5KvjSe0jX618cgFIcVqQGBwUvBWqBeGRrUUL9FIAb70IKsMupV1NPwybNpBV3pt3yE/u+1bcSaY", + "eUUZ30oMAm1L0SvUPi0m9k3KDKOcfXDz/dQ3bWXy+3u29Z7ZAxtNccse/5rFjme3S6YNFN0o6WIyiVQ1", + "Hnp/JgOFUwW7pXqdrBeNdammNIUhhjD20MniJLav9l1gcL2uU5S3tu6WgiNAfySm55VGFm7ntNTG+U/w", + "+pHjdEgG8sLoIXkjybRULi3UKpNeMM49AyYYsc10uNu/xRWO7dr+Hm+9x9XBf7LL3HlQj8I2W4htl1gq", + "GNbfjvw9sAzU3QOL4eECkAUoIGihKYvKvUWXmMhzWnK+RDYrVcgs0L6QTc4bGfEBme87uLcovrKqCMmg", + "qzLIS0cIgmYwK6t9mNEC/X2cfH/SFsMxEVWIHV1xNwwaFaNoem2heVGFTBXoeVBSME0KyYT5TenMnsbs", + "TGM+KXm5D2kJd7WvUsBu3+rznxh6DXjLKngN+0L7KvXZ3zXaEJvk9v2p63Z1KgoLUExmLG0U6QrajmDz", + "vfFOMf1uYA3ngS7hyiL2d3DrHdx4BA98BWOns9sNLETEg8LVozwCkcoMMnLx5t97Imi1bZOlga1SeiFm", + "m9b4xnGos4zDVs+IwM1YFjy3V/wiKPn+6dNck3+WDIy/d06nLiRh4mjKMaEruuB65/ue1jY/9H3v24od", + "fH/D1m9YU6n4iHfL452vEr7xabiOgNz1Cq9Yn8DibOpFZBfVgZmbuAKaLe3+eNxDzycrOVKDVRIyciAk", + "KRSTiozD2j2IscvH3LAUM3OYkHGpMJ1piIuyf1fhTGMXczVW4KOo7QaMGykjnpNxBBkxEq+gyr7W+ZIU", + "sih5neOcGpJSDX2zTTzQZek8oj1/2np7PIY+/it08yE9sJ9Qiomrtp1Z8wKGHquhjehm03KHWz86DEMd", + "xV2v34RQLQxVbfzmVVoCzPHxy3fvRidv37x5eXJ19vbN6N3LVz9fvjyN+1b6SXcG3oVFNaLiquR9pqqA", + "QVEDtUJGOo1XdtQGlYgP7Fc6fOebXi0LaKgDcIS1sN9mJIuP+P1JyIUIOZOYSHmZATn1YZYJeQUmnSfk", + "bz++S4jLEJSQS7PkoOdg37ZY/TYh55AxmpBX0va5gltzZV+2CWnc7oT8ApNLmV7bbudUsCnO8ELB1I3x", + "1sxBOTKZSwXbFY2Ns2lhRVIj5EZ/I7+F7xyY3lwmHB+mr+gIlnt88tuc9Z7wbiW8/tAen+KuncsD09oQ", + "Ab01DUsVKo1ygtP4hxBPvxtR2jNvRM/tMu9m5N16Gni/LSHCbmhH8nOy17aTzJ2FNkPMwcNExlJHTRdO", + "/Cl1e013pnnaU7eCKm3pUOHy/jmChAkOotvF9EhBxpRFhg03h+maVehmrlo5xYSbDsJwS2GuSMiiN+pQ", + "TXw6HQS+YCp41v/7y6uEXLy9vIozuEJqMwrkJ35mE5ktkbVYKE8ufr6qHmmJXRy9oYzTCYcOVuaWFsdX", + "V7qecoy1nsBU+mRGoRceQ52vt7HZuI2qhAfi2gkpBftnCc0I/YaZZ8+h78+hq5SwLRJWE5w1gtCPeetC", + "Cg07cG/XgShIARMu+2fiKzvphuqyaojobw/F2wxctwTtjoiVIWrYWQl/G2GgsQt7aaCHNOD261OIA6sn", + "88DygMXO6CH5k2ihcU1OMe3a1Kc0I+dn5y9dyp5PKhL4mTVlgj68zgs4MvCOTdJMzvIuGl0tOgCstsox", + "TrszT+Ym50lI+Wk7Yob9/Vvxd8+JMHWUiZVA82pmf9auFUllBh1ZD7FBh74hCquR7eLtTwl5Iw15JUuR", + "Hd6VYfqV1BdxI2e8oDM4UVTPN2hOCzqDb6xIKjJQoCp3utT1IwdUkPeDF4uEXApa/P/eD4JTwSFZzF1i", + "x1ppEzozo4FP7S4sLSfllhmSdyHreCh34EfwM/AyVtKIIfCOc/Zsq5RD2nYfO+wdhiwl4yE5CRGVPo1k", + "mNrYgh+TQLEt+/b18/oqSy2A+3Ln1ZPYc+ZOzoxeyh43HpErR09kN6PdhkxZdW6bJo0PHvQNxP+0CbEw", + "VSidOQcYKfxTynRf/+10qjurlZ3FlgM4lfmJy4rxWtKsh33n9O15q0NIBGr32wIcZhVEhIWifM/Enw91", + "z6OL2l/4zRc+k/nIJ0hB08ij3/3uU3pok0hWjKp9i1AK55GWh2SDxDnY+DosggTnGmp8pra1KzC1+5EQ", + "BZwadoNHvMqPnUvZgX2n4qlhlsfDIflZAxkb7bKvLdruPZFonpX9b69sqyTyGiNP+iZZcHEqHUkWnvlt", + "8Y90JGkYB1W7EhhQN4Dp0gKkOZuinqpWHN4wXVJud2fCODPLIXlJ03mrg/Pcc3q6Z0d+VLto9emIyt4n", + "oR8NaYc2PTL98NhscWR75uoyL/3lbOHWwcnry0OP2lU46gUo3ACRArliOXAmgLy4OPu0TGx1eXv+1Q/3", + "7IZ9Ysx7FNuSd7GMVGtbCQdtITQIo5ZrfqEHvlDCU2QzLXJMClCYBvowGjza3NVRBoYyrnePlg3XqbFx", + "hBqj2KQ0oLfcPFzS+t2b02ykILXiChaE3IzSrU3y2ZRSyJzXA6ZqRCDB5IA+cgmB25SX6MbEPH04eX0Z", + "R3kUFyIBts1xdSpVUPbgK9ie1QEWlrc7ETzkX18exln/Gk56bdOO2Z9DJij8vi5a0dqiKtl09HXEYnW4", + "o4dX3/cYtm4PX16NZ1pZsJ9LHUjcQwhKi63s4rV9RmlDvJg3LTm5oMw+c16fXPxe+YVf155PbOETafHY", + "7KF5Eg/MFnha3JEMe5yuUdph9H3JsE+6FKU+LKvBh/v/+uSiTrjJpsEI0pmAfhQnNvbl5WIg1uH2yoog", + "ZNZNMk/fnhPbIEI1G+PEddROkdMx7Xf4Y9+JP/cMG7PCHDmThE+AVIWGXbGcidnRC87l4siZ8ONZINgH", + "6E6PShXQjgm5/FNE/7OkbX5Qw97m/tKEiC66dglEKnLDMpDhp45s7o/L9JpTszTMq+Eenu/hQDHh7M5M", + "bzunk3T7K79+ua8q8njo/luo8Kq579nZFnYm6aM/tFtn8TtXzqGMWaPz56Kaq0sE97uxzQooLrHG2v1F", + "euHh2vtLTqhSDLA2SFUIYOpqaTKBVGuCqfQN8eUwfHm1ULajqYlbLVjzaanDym7tacRmGlEf1iNTiti5", + "7GbRuxtXFwHLXYtdqxm9gQXZXNGIUK3ZTPgQI7wSW4oaFVRZsbh7PRfYYH1JWMnE18ZulvF57oOT3Awi", + "BY10R0LqXasVPVhNok9rWa1xwMgHqwvkvCIbkleNRb2vwmZ7SyjpjIbgDkNcVRZpRcFO5vQGyESaueNz", + "lR+RbuNOy+RSWaCZJg3wzhKDZVLQf5iciQwKKw27ggnNmMPnhBLNxIwDsS1c0gTnG5VJcIUqJ8grmfmU", + "Ph57M82u/OATmWqu6ORtAWKD0VHAohJwDJ3Yx6GnJ+gogZ2dbOPIwTDEhl5J9wXiPuK166cPnRuxDq7s", + "tJUKjOk6utQnKrZTCKX5tGzlEt0WSerlpXYMaUNwqm4Fop+VK2PxpUNyIoUuc1D2HerCZ1fkNKxtFeoZ", + "zTHlksE8hMxYWY2iJp9RvlMs6kNJZe1T3gtlmy+hoZORw+tPevnuIJPhLOOS01WXh5W9wxjs5K8uXgYp", + "wEWpiOWuQkbcnSvwOwELvqyGopNHkTwMMzyi/nFRUdzTHtumkkqRoMQnExVjAqiG6qwbxoN5gXFqLA6/", + "KNgJ5byTQlui4440pwJVkE2/07+eE0Vd1rY5FSRT7CYIG75JQuZUZI04Y1cb78jpM49owXy652PMXqOQ", + "/HE2hXSZckiwhrkvx4fikH+9u8n4+k1VwjjbolG1espm3j40JFdz0KjwJLnUhi9J4TfgiImsTKuMe4WS", + "WDZd0xtIiAKsJO6rhhy2FktnllK4YhC6N9H1o96b8EaOb096u0mv364RLdjIovRjEt+uo9k92zRevlaq", + "6bWFVHmmyXkoMioFXx4TWmG4u8NpUANJ+870zw//4EDJx6B2HAvOk3EqMxj7E3bF9t1vUpBxNXQM6e+a", + "3dpRCdXLiGPHc4PEeMY5pq/OXPXsb1CKVP49JGgOYT0hy7z9Dh3nqyn4kqkXjtS8vIXUSn+Ye/NdIFHj", + "3eNP7IFG4k8q89s6YQytc5ZlHBb0MaMsNkVBtPa7EQvRMx/YhZK3y5dKSbVByUmFfZIWtukRp0u7MS7c", + "gciJL/baTlQxJK03tP3FMgFJwKXknkttjhCeO2krJodhvr+99fkpnFP2XOpG7FG4KRjF8LcjV3/2CFdx", + "9NIVZ3dRIEPyWi6ObiQvc0DeE3pSDGrK3GaTM6OD5hr9bo6a8RX2s2X8wWLKfEpJ30Y73huy5XhG5GUE", + "F06p7VMgP3Z7F3bNVUHFkAy3f5hlvI7OsIJVAJBK7uvrsKrWracITOOTSRBZmCMmiIIpE85sFiK26GKl", + "OnObSZIMprTk5siul1s6IWZEs5mg/BMnzVlBwz237OaWdqdGeEEek09GzuOhrUwyg2i97IrIOCpg2x1v", + "uu+qHSjZrB3YSVXaVKuDNlVFi3OGPTGlAurq8DaDxYljkoE2GJYoxQg1bJAltsUNy0CNJpym15xp0/q2", + "FApoOrf3PnHQRqWoEgUkpCwc4UBzlSxN45tM6JGnI41v2+l7h+RncY3BiM0tcWTHl1NoV8aOrMHd/rVF", + "NL9urMJ93V6GfeesrKP5VWMhza+3JiLeKQjWpzqrgjiRHeHWPHqI6qaw2wcLSt2ynp2kjyp3orsxjdty", + "8P3Tbw93FUzwfldT2GhtvQR1w9KtUZnuXZnh5WUpELhlBguDwG2BqV75ckjOUGhGMd4XmHQio5OTpDqq", + "U73oeWkyuRCHJJOoCvTl8Ztqwv/5r/92nLoeBcfVLgATVO5jrNHkezRjN3BUFr46FL4+SSb7MlP3lr4v", + "L43s5p6fdvJTj0yfILiy61zu8PS0INpvz5Vl1C/Pl7fMIA9FhHXCHTICLOd3W0hd88FSZKD40lKvti5H", + "Vbn90zkVAjiKoHgvwmvNXkhHFs0ycS4fQVFEijnVUEd5Vp7MhAmnrT9AOlZxjkPn8nh2ihNVPkQ6dosQ", + "cqyGUo+hh2SMl7YsxiQHKnTg7bjwjNl9cXYKhgkGFEF2Zx+plq9yM18GidzlNB+Ssf8cAFJSKLhhstR8", + "WfVpjdAmXuMZvYFRfELhJKrM8T5G1fnKVMnq8ZSNK5lklD3L50TUBRy6EMUVcpiypjN8OFZX/0hLy1ob", + "2dh1ZUqs7pLbzkEy8PswSAZ+RVGiVkSf4menazHBbguG5MWkTnYU2xs7GCmL9eoW0W1y6hQuhe1a5Zqn", + "rkTexdlpR8IDv4GC5nH5daZo3q6m75cR9tPrMLAMDyvzcULGeWkMKPvXmqZh3KemSHNOib8Vm0gRMpq3", + "Mv+JdaqXr+ZAXjNR3nrlB3n79vzomnGOZUCQ72EK4zq/gdAsc3ftr+dD4jiHLyk4fpLBzZPrXM/GwQZo", + "0YyK+jog6BVNdGAaOeRSLasDdebzEAfi/dGqaG1dTjxMCC90T+50WdiN0v3THDwQR17b7j1D7mbIuFkj", + "KfORRYnHZMjxY9mdH9t5rrDj9iK6Cw6mUmijKIvdwF/m7bsAKcucbTxcxSEZCykgsIsZlxPK12/LczLO", + "IU8bbCmdKVkWoSWePmLHnJnnZJwWpQYzJk+wn1TLUSE5S5fOmP7m5/MXT9wXR5liN/YFwjivybMUfsqa", + "SJ4FbdP3w6feHzRjWVVM2NepVmXqEpWMpcxxacdjwpmANoOxi8XML3lqeYubp/uinmXHmzEfTRXA6HoS", + "KQStAIg3ZPktYYL8xH4IhbSbwQF2cgnJQGF2tEpDPLbQj98EvbzPkef24RtNziE/OhNTSbIyL4bkhdZl", + "jsrIP+A4TinBPsCQnAbHhJBBSEHKKctRSZhaASSUoNU55dyrOzDGmxJO1Qzw1EZGGspH15MxFlLUxuKo", + "PX63426x9sjtUCj4kTlVGQZ/aCyP40/Tk5GAhM2zoy4bJM6sWqD21Szw4Nave3NqEcJlf7n3UbzB/dTk", + "3Ytzh0X3OI7H2YVtko9nhkHwicNwP3YIIicyz+PQCMZU+LRDbXZ7kNNb8ux7K+UrnTR4RatZh3lF6+iR", + "vgON7wKiwThmE5+VP+YDXeK8qZDiSGntrMzuL5Rt5znk9uPhkFx5HTiKgvOlZmlN/ZrioUXzUqNwF0ei", + "rqrxxchQfa1jeFqQWsiYYAUoXOWRBnOEq/RD5bKpkXcYq93eW5DOV29FWmqh6vjKTsG9MMbEewK+zAuz", + "3ISU3uHDtj2h+BighnyP4S5oPpFkIkt0YXRcC5EdkZUZcObBXQUbO0+84fT2zMH4vtpVqhRdOqGFzWag", + "RtsugG/XeIr2uYqOmVCRWUo2Prn4+Zi8sZK8/cdeiONgHWrwlsi5hzn2vmAVoqGxinIuXTa8SmHYSMTr", + "520kYeJGXjuBuZath+Tt1PjnDfqMUk3GzZmMyUEDjL9EDaUgqEMMGkipIBmbTkHV7yXfKXXT9D/bPb1h", + "qWH5kJz3uf+tfeuqn9LcO0fvKhLRVyRDhNpNGntROcH6E3HxXdtuFXKBNdms/7nfh25uuwkbeUB/otvm", + "outUqYd61x2iP9HtZ9lw1drkO9ZMMekctuyTzatiHWZX2R1b7pHJgKEH6CAZLKS6BjVIBhOaXlvRVmQj", + "3yY8jasmek4VZPVnzF0dlRnDOoIL04l7XDDQJ+i/dCenEZ9xr/aLCmZjDcYwMXMP4+Ao1flsoIVJ57vb", + "5FbXsvQrWU8/euJGIFryGwh6EyJLk8ocXDLSRqH+R5wHZ+Biomk6f5KBwWRElX4vWEQsQjmznBUSeKih", + "HuappfMTfKxJuhHs9hSlIQdczhKyoEokzoxyiLOyRKGczQ2B2xQKHx/i5meU5PeYnwPQOb0XMyuZ+Lea", + "d4ojdEaZ0KblMvg///XfoVy6OvLTQg8lnZALTpcLhdWAUJ0Mt5CWThdTF+DSCUk5KybSMmCKtSSTpudf", + "DVTmORVZSMt+AyvH6DPgG1D0sTHsZ5dqpBoqaEQPUs7Sa52Qa1hmciE0LlRyS8c/JpXnw+NNrFnw60ll", + "rQvpPYcupGn2mHh9gZHI1WULG9MMY3FFAFbSib0+uXCbVLlUPiah4lw3vWC9BnLN+bWdlfWg8mht+rEm", + "gd328lw9HJLzuMPqcyKnU8v9vYeNq5KAfjq4L41CfI94eqGC46RdMy9UxWve2yH5kc3mxPlLbZ2904o+", + "3sx/qF2YndUkIbpM51YWlqU5ktMj/8pDxZPLc+yMw0dBye6U7pbAftwgonTMaDfGftLECafXDmJok827", + "ESLpOlzm3Zj+DzwGM93K1etFhmxIzgRpFpwgGriv68u0P7FjInNmfJwZ014/deD55mIuUa3kgB8SDvTG", + "u9dVI8rp1Guc7Fh+cE3glqbGWwDTSjRCidNIV3wC5/fi6uTHRkmMrtloH7ZGBQF83brTIuN/fRwfoqcb", + "EfJIFs/bk1NgLB9DYxia9qzQ66xxV9JnNSZSkYxp/JPWXW8YdbNLyFKWJC9d1aIMp3BbcJYyQ8Z2IWML", + "YYyHP269fio1eS8ky4o2p94NzS6bAiLqudKsGHlGWtnx7A+ncHMlJddeInLKnogU6XJpQTZyTjMRPce5", + "+8EeKGKGvX5BWG8P79x3huRteIdzpk11sI1TFXDoyuEiCYIbUEuiy8Lro9xMhuSlnVoVFta4R15MDo7U", + "goRFBJHCdnDaTgUcU237YLJAwkuRzqmYQZYQZh9HecGX4b2BFkFf8eJnjZZfI9FnyjlCs9kctAlupX7b", + "UGwan4miNMOM6YKadH4uS1/VYBxKYlMyL3Mq2Ac711Jp9K6xpM3iFqYH8Ferrpw69ppZ6kl1SrVzSQmp", + "ZkypRHA+al9le2t3V+ggiuI2upOvDTRBhdML0e+C3lc1qYzQ08qx+HKdwjWQLpzxNSxjuIczRvRrOusG", + "LqfBHBOsd7kAlHMr32rKveeE9rnhJE8aRdQTEqQH/y47HJJfXNa6sZ/ROKndJRrE0tIdSzA9DzhGsolW", + "lkDjnxMqls7uLr1PtV34dIphM66aew3vwL91kuD3m+CbOmnKt4cJGesGimG4ZBBgnIknwv6ROk7Abjl6", + "Txg5JC/q5flDC7lm3YT9qkjKgSpHmkz8lN1ixr5OcSMz/YErVx+coJ00cOhM96aGYSn7HBQ8x6yAXC40", + "oaWROTU+MnMxB4E+HLS5ZW1uGrEJ++X1DU7r1BR8TAZwa0ncrpBeYq8ApefluzOLWZdkEF+rh6AXYcgl", + "zYGM/fGOiYacCsNSNC1QsXT1wKr2CSl4qdsKjsZlXX8Tth/0gX3FTPZZsfPRrDJhezR7CezrkMBat2m3", + "q/FKqgV1EZByWp1/g54Z6WZtQFl+0XCpXkvcgI7kDSw6Jo44eMpOSsFRd+13gC8rSmppXIICilPqu32y", + "XRvjIWh/kz2KOsQ5mJYoFhWcpnCIoSOepXggLkOEzynkvzPSoV/TIBG6NZDOSiK24XPCcDjEucYIDs2a", + "yB1QxQt0lVgWu+jS8J1v+tur1xe7k8+1Xruhie3+BBU3fvsC1bvDuy+CZAypp0XEWq6tj5rY0duY97bF", + "yYfkR+pE3OnU3uyDMElDl5owYQWEGyxiAwKbbcOt3jfxxDu7BQ4DGLyx6x30UWdozpn4UMLgRldPCyVk", + "7z2hvc+J8wP14RFrR5GD1l6Ttm6fibuYVqONHOicFtq9a9DT8EmtU/K+Mk8saRD2SfPExzE/sY8FTpfE", + "CmrPq2w5HiCWxLSU1SeRsRhPDfNJ+RvGkJWZoImmCSlq29AGIqnd3hZe2q330jZ02rV682p/T1mMwv47", + "X39lml/gn+D8cuxWWynIbwLGKLr1h4aszEdTTmfanY/dou3eX2HN4QhjBqkTztJrfJH5mp07ZluYlMbE", + "0n4jSOJ+dQZdJ2SjFNzYJw5TM0gGqDy3U8WQUW+tck5y9kZHzwl10B1hIlfecoZtvF6/GdQjFwIjawYe", + "THSAueTZ6BqWsce/zFyMi/3Zrs+2Da9ZpDwItfHCXE/wsGL+F2U+cmp1NxxSpcHxs9Wb/gbjiVHTwHLw", + "F6sAb70M467bQ2/XV/E3kkpU9NI6463bsUK6EI0opEjVwP9zF0gr6Ho7sKA7kNTZTXw64F3rLkULsJ14", + "JlsbZTB1jQ8t357BwgKNTtYbiV7Uxpk7GOmDqcnjrj1lZ1VCIaOgytf/RVLvn4h2KsHJzsnansS/FzWU", + "wuV/daYnxyOVM6CgAOd6201AwdU28H0LqmgOBpR9brz04rUU1e+uZyvmC+3X4XXsY+Pi/sV4lXNLM7aJ", + "MusE62MyyBSd9et+quhstXcub6Bf73N5A6u90SvQkoltnS9sw59g2ejrjGbbOl5iq2Y3MCOnJtvaFcwJ", + "Nmz25gBbJcZL28ijcMMTed0PPvgorGFYiw83zre13w5yKL5fb2W1Na2zba08LCRGuWugW5Zp+cQV3Jpq", + "e1ZvebyacDI4UUANnGJBaamWd2OeeTTOt5I0sgCd2IbkQKboAYqrTAhGSvzb998fDsmpYxbIC/7t++9R", + "iKPGvrYGx4P/9+9Pj/7t1399l/zh4/+Kp34080hI4URLbqlNPQnbEPWDuPSVQZ4M/6/tjk92pNhmngIH", + "AxfUzO+2j1uWECae4TAPP/Equ8bdZh9zcjpbS1FVZxkKaRiqFSWOJbgarJI8qZo+QalzSF7wYk5FmYNi", + "KZGKzJfFHMSQ/GLfMv4VmrT0veujMe1Hy1bRix59eHH0n0+P/nz06//+X/2Sop866bbnM3KlkgoqoLv5", + "eXg5uHZ1TviO9PdTBXo+UtTAdpC+NbGtLeAfP5CDnC4tdxMl54RNUfWagYEUPUwPo4MuWBbD19XRsNnG", + "+Ue3dpXBPY48b6lyhyxfyfBOqI8GCIF92zTF3KerktCpbbJWGmgCZgEgwkSsHO/D+qhL0WEkseyFUC6r", + "7KEG8z3nTLDcTvRp7Ew25v/xeePQN7/OALQ6t6C7tTcXswnQGc4lr+LydC6lmf/FaR/RMIMWnKCNtwK9", + "XcOEal/1FgdE8sVBzPw66K1bx7OnT58+bazr++jC7vOIsUvY6Q0TJ8RvFRYpcGZPOSV/v03I8tfmi6Gg", + "TOnq7EIdaZf2xU5iho7g51aS9KIpoYZwoNqQb0khmfcErGa6OuVmlEXlg/0tbl79YXU1G390Z9nCYXuu", + "EQ8qZ9s84uwayA/wgWGBN8zsE7AZT3hBl24hhAltgGKiBs4EUO+CVUjuNVdIt3E01EHoUQFqpGGGmOau", + "AxQjvGSj3OW2YDMh24UiGmFsreatJX2/472sMtfjvNZO8MzNYv02bL2fa+tsP5Kfdr+Sqykhbrl5YRUx", + "v18+/gXJRPcEybmbHnnWmuuz7Z7BXbJDpeXrq29bAbxJq/PSPRVrv8S+zGDVVdq56DcenyuujpGgvaxD", + "HePquj/5D3pD3Z/OV7KG7V6x+OWcau8iaX//Bit+J+Qbn172G/d4/cYbyb4hN1Qxy279yzQvOByT9wO6", + "oMy4qskzaeTBN3NjCn385Am4NsNU5t8cPicKUEPfaI6JMQ8On78fxPz5XUUTl9k6beHhH9fw8NxR69ol", + "1AXY1LUgKundSlh/fNqi8N+16Pt2XMPN74kPGie8IzrYTvZOdTq8rmvyA5avBMliBhWPwlZuqvfHZ7eJ", + "p4bxk15/hrpQP3eSdXABTu7A5XA9dGQkAxWZz2WIjHGpXaqgiObCIoriTMYq+VXAvM9zT2glIvwmSys0", + "dxsy4rtMS86XcWNDKxLeDxBHEK97/rngkmbnJTesoM4mv+oYVWmpI7klHIOv29jrWyJIvLJoRLGidoNV", + "9wkseeWCZTFzgJEEjXR+R/1I3hvoyVzm8MTFlNRWBf3kffn06Xep7Y9/wcoT5sXRf9KjD0+P/jwcHf36", + "r2fJt99/H38sf2DFCJXva1P8T1YQqtK55TReRvEG41IUNL2GjARDT2PWB/6uk++fknP2w3OnLQvhrTkV", + "bAraDP+hpThsxoxPmKBqufXhWk3Xx6bEjr4hDz1tyjzPIu5NTdgNRIiBfcU4nImpXD9ipkcZU5vxHCUi", + "NKtX+oe46JLLzpprVjjMUcT1EWehFE61ixk1cORLMq6HRUc5mV2WU8dMmPEJlBLyfpCpxa06sv+9H9gn", + "9vvBkVocqSP73/tBPLwpjus/UA2tHBmY1xedBNZ3orcaJ7yC1skO+wCjydJA5D5f+ugo/Hnoy7uFaTDQ", + "PQKjQpAbxZdiY7Ak4EHjDP2md6GTi4DryMnxqk647IzldWjU7uhH0QYMWX88vOtZVkPd9VB3w5K4Hten", + "rFgW0FTanrx7+eLq5SAZ/PLuDP89ffn6Jf7x7uWbF+cve6SfcJknOkXgn4RciDVHlPj5njL7KaRWKYXP", + "wVtVYaj8ALyzbyj97iUBlx3R1fKro6NplV+BcmLorRQyR9dZD8bldmq65jlfXh/NOs6ooc6/T6ocmZ0U", + "1VmjVGqnMgEuF+TAmWTclJytxnsOjbv3YZwQBTOqMvR6Qf8YSYpywhmmzWFmSE4o56CO6i/9BqAD0dvL", + "K/Kkmv0T/1NI+lJl2AgeE0y7nX1ONAAZr8yl0nAsmAKi57QAzAPJsiolcoqTCaHTzUgqpqsNDnHpqa8f", + "8Y0OCfeCCR+l7qw+cSdC5rQoLJpZqTXkw97s8NLKEp+E0MBROrfTFDMYBZFys+ev63YSejk5eBUoRgPu", + "BPDS9tgEzNdK2wHapetRgavd0Ht7N7f7oh9vn77YsNnXHl/f7qdV2wqCc9b12Vy3AHBtG6ma6/5czvr1", + "fi1noW/DIdhZ5LdAOKvbo3UyBgftg32h/ATLGAxnEquK4PQG5+yHrcJOyYCzGxjdMFj0POTX7Ab+ymCx", + "ctI1mN7nHSCtH7r3cW6A2rrMc9fltNFjFRoTrMqZ2gvYmWDmFbZfBaVgJQdrL3jvQq8tQHeGtw6rGTzX", + "B1QdTxEgNStnbYHh87CeZRxWe1vqz8Ss3zZ5OK9dn/YmBYAqKLD6QPJ6q3UYLk60LxDXOkDBMjghFeb2", + "8kKtjK2+93qttB6ATmXu3V5eY5cWxHaFtT6VarHDCi1ogZqzqekPyLZug0mLHcq/V70kzXapsxv6NWpF", + "7lyHcx3GDvvYUTAvWauWtGshqkESqfqxe1GVKut0Hwa6WukgWUv9unNa3UGylq1u10SAPpGTfbot3+Dz", + "yr0gPiYDKaB/bO0qg/+Y7NKtsS09O8aI0K5dm6Rnt74RKrobgJqc9+y3ij19u0Vu5A5d42RxBwA1Ldmh", + "08pd3aFn63LsMs1VOrtL30Bldx+vSdTudKB3gRAXpHfvXMnPu3eNyMo9gXRIVLv1Xpdjd+u/Jhresfsd", + "yEeH8Nyzd4t39UW4GN/rS91X3ru7dGu8Wfp3W33t9OwZfXbt2PeOQ3fqFe7Qv6nl2K17VOvSE0RUprhr", + "oQBXluE10wZVzRG1rFJ0SeQ0ouRlwtkcMIWOyzM47Bt+XhlSIv42lUwTKQnB5Ww1xRstCu6NIRsDfVYM", + "KXJWWWoN3MbLdlT5jiPmWZY7Y109owXVVRqzvhaZDveH5tAxHfM5tfLUb+WgmVN1/YDumRYcYL0rmjXC", + "3Dq9Nnd01ewyY7xpWDDcFBKCSRt9Sbrziz+QdE4LA8rVFPVeDK/RaW1w/K33Ywifn2073E5j5cpp9nJi", + "6GNgbq7Q7SJkfqlRdJfTqQYTdRa8UPKGaefB7Zq1t66+jo3jsoiQrHpVJSQHqjF6sZmRzKXrRzcSTOyk", + "rp1lE91naGnmUjHjXJ78+EHf7o/IAVgoi1joSDdlgnL2AXqlJY8b+OoNiR6bLDVc+Eigd5UaZtUy3DdE", + "KQQA3D00qQtC75CktUiQ3bDwAd1NMTTino6mGdOGihRa3kffP7Z7qZ3zTu6l9/e59Abd2sHS/kmFWdnF", + "uI13G3rW/qsBw4iRd0LTvpB2Qte7x1dkoM1oW5xIIxA6OBtsC7NIBlql2wC7YgW9Ya46PYUBksYqYjv0", + "9rpJl3bwivt3V+SevP2pKhu2LlzJ661YeyYyK5uBDm5dw+0uXfI6upYLatK5j7G424l3BVmcdgdXVITi", + "2z883T3U4rQzxGJIzqa1FFRqnyPB55uqKyO5LnUZOEQfLwN5l4Y/Pk2+e5p8+33y7Omv8Sni1nrbx7bz", + "mnoXbAVTSztcgDv7AI4EV3lWrURXi3y+MreV4DChQJzS+Ej5Ol58Xf6sR3fsvHIucwV+6vUHdxgjCQgr", + "TRBmCM1o4eLFBCxCdYXaDxVxAvdyDjSbljxx2ZfCN7wDPTtjW047Y1oqtPnu26f9IlxW4yjvxnm3RJ8E", + "rhvYlktVvdQu5GS12nMDRe1xP01cW6qAGEwxv93BfQMjrQIC820c9RqWrkoF0XZzPEfvz2Dj4we3Tgtd", + "L/OJ5Dg4DjQkL2k6J3YIouey5BmZAKGNto0MdpMluc2kkZK/FwcagPzt2TNcyzK3bxgsPyiFPhwS78Wt", + "K1fI94N36Nv7fpCQ9wNUh7o/T4zi7q8X3H/16vv3g+F758fo3PuZdsEnKU6Qci3tLFOZTzzL0j6e0sH7", + "3yY48eEnHO1/X9EJgt1hQ1eoNe5ulF7XJbsfzFGfVkn/9FJYOiKw1No6a6Jq1o75+Hskh7ODRNWszGE1", + "1mYrVlE9UlK2IzbiyyjbtcswY5rtSgrFbhiHGXSQHapHpU9GthkkvliZtnwEX3ai5K4iq6fx61kmgvvM", + "mtMcbnTI4KTnwKs0XcgLynjd0XQRS2sjFRZYqzVGB7Tp5HfoIXq3KZ/MUcQWsF3mAnHTjV7/igXr+TP7", + "18fVA3spbpiSAh8eVRAGVs76/9j7+uY2bqTPr4JiXVWk50hKdpR9du3a2pLfEl3sRGXJm9ssXRI4A5JY", + "DYEJgJFEu7x1H+I+4X2SK3Q3MDPkDF+lyMrz/JNyqJnBW3ej0ej+/YSLW/EiSn8p+QuFFJvVTrQvYHuJ", + "BC7nSjXcqT6CV5UuLlgcx6ISLj0Pvo7jbzsMNjMgiFvpLlq5pJEDIlBgthD6QbnExfBPR825rRXkZHyU", + "DYvRqCVmguUS635MF679Y1/aV+9HWUIlbAgiiwSgIL0qxtYq0ltfMkS9rBm1zvnr9+86y79bzbClx388", + "efu20+2c/HTe6XZ++HC6OrGW2l4ixO/BFd12N0GWHnZ6/o/eEGsXWqch0VmDyP4kbkoG4ERnxVTZVYVz", + "3Y7RN6u+5R/ZsAIPvtrFji6ZsbOc36jqhK2F392wdS/yuRPrjbhwbrZ6FzympxlnuRVFqntx9Hun5//Y", + "nzes6NnDRhSzBa8F7kgt22Xzop0A/XO2sHCEp1cZBEQU5+s2N1jShZb8Y9s386WRQbu+rlvY85PKrQ0f", + "eoPEmfVfW6YPjZw1P5/FxWrjTg2sQE2vnwlzLUyPW6/3Iq1yrDdssjGCWxQybaE+9+74BXfNlzXIW7nA", + "JEuvbXBf06pqkXt9EwDWCn4mUrLzJVYpLy7ypGF8r62TUygheHn6gRVwqZULkwjl+Li6CyooIF6xjZYM", + "0rLOejThljjY1/FRkPqvpQin7HEgUgs8btj7WJ/TsoM3hltOyzV1taKPkp0Yu9+8F7UvbCrVdpvOK+64", + "t2Q3RmIAdE70sKJSApL4ovvEHV/LsUirrawmD47f/bhyzDv5i747hF5h/ecWR0i3NW1CUpa7wwPhcqff", + "WTekQkMxgpcFVpv4TmevI1+eEbkR1luoClk6lcJqs8CZsutqxuu0UlgAbr/x6NN8Wf623qWFSiivCo04", + "JmuZhmhI8ePSsgG8OOi0qazvf8MugIFwqkDSFQrjZFKoqzr6JFQmx3rnNZUYS4hg/XeLQwx1OoOtiaqS", + "AnQyToAi7Z6vquov5Z1uKlkrYbdjjAziFOm1tNrMnhFC/pXSN6F1QskL3PzCMNxW52Cla/eoGRLdIIaG", + "rWBD99kJIhOrbIY34r7BQmGDSWGdl81ZLmzXiwHGXgG9FG1MncI3kHGVBErdSOdWoXsqWbEqJEI1yrFI", + "Q1Nj04nVRWW9wFL27jYOApxH0vb+zlTdKyoSK87OanvdCteGOQPCNFckj6SC0rl1PKLy0j681eYPrQwt", + "oau3+LONGQ6Vv9fgWtb23+ZSDLbu7Nw8g19Z7WfTnJeZkO/FeB0ozPWuoH4gAoqQrDGmeMgSlK+WS4lf", + "4DJikw+tmaCA3/rGn8zyXiZGfiMwSuyUsrDBNxtvhcMsdMPErlqybS5XTFzoFXiWdcFo3I3qqJebXlhn", + "jl/cLr/j+UEb+UkrwFSEthif6kK5PsNMFX+Ght8tA6iTLlNizGu/+3Vo3sSxByswzv7ue5ys0X6qb1RD", + "80Xe3PguSRkRd3P9+P4qreCOkMZLcNB6U5srxcafXDtTYgExdUOrJdNUqBUgLpjRUV6X0Usrr/vpuZZu", + "v5GZOBVmKiH1z27X/7HRRd4cg4M/EZqBYd/XAhmbwmY0QJn+6ehofzPkUn2jmq58fF/hT3DJE/r7oaW/", + "60AsYLV/Xs4t3uziJSIxM2yJKroE8qIKwbshnS4vrKhCKiHvXi4Sr/tpvEbY8B6ieikO2LtN1xBV8Kpa", + "/tjhSqWsNt44Id6FeWN/4S65U6DYiOILkQEA1G6Gn/KKK6/F6hBu1Hb6HovvZrM10npak5RgBnbMZgZi", + "6OYknPelbxse8ks8yr3GXgtjZAocOnBsohnYr67508NV8eDG6Gg4uy3ENeGoNJfTTKnH/gyJeZKygrCF", + "7EplijUTKiVUxT3rdN6ljGy/oSI9K4LaIgsxzzJ949+aAv4VwLCrQMYSv2nvDFC3ElHdKEt7ym+DLp6o", + "M9S99uvTsunq9WFII12+sEvXcspvAZZHfhIn6t2L9h5AQUSgWn/3Yk1hmsc3fdKSVuZHd1ykUq/Wy5dE", + "bcf944gRa2Uq2LVMhe6z96iDthod8C4SvxaMK3qL8hG9vJwWmRXH9GtyJVyVcGbPfwTwZhhwBg21m1T4", + "ZvZJWjDVqp4OLi32qKdVq71osA0639U0aJMI/53VM3kynYpUcieyGfOKFWkXx4YnYlRkzE4K59WM0Ham", + "kNwHAU9gQUq0MQUQ5sFQQUaaL6t2KL9Alf990LF9W/mdoGOXsDvqWmQ63zQj9RxAiPFVFi+NnPY+QAUx", + "kM1BBjXQMIVw6VII/TpwE9AT/NZ649CbaqWdVjKJKWoMr1rKnvLEaGuJKXUkIOmDVhmVEglIITvoLbeu", + "By33Tl5RDmZB9UZnZ69DtJQ2CGkRLBjjbgulDhtcKvsxhnjyx6Vr2FafNYdYheUbN9KIXiauRUZhNkBZ", + "AizUvIJmRSsXdzewRgHxijCrytH32bEZSme4CcBT5HkjPTShWJWYTd5ApvixPnujTQTZWg2t1W3CxIIe", + "C9ODcB6KDUt1AqlkQJiJnLkUH/wPAps6mPvlFXy3kibYZYuIWo3kIusGkR9LKLZczf919vNPMRLbtFSZ", + "tDTFy0HGEHMR72/ml65OENO0KLimfu53DQabQvnlaLwDd0HgaGeO9yp4DQT0MzccMgbwI34AI2K/Be8j", + "k1PZUtvhGhyoD0reslhdiIcdb5rmgHvLiSJPEQzWTWX3WKuu6vcKhce1PwtXw1tcwrdx1S5ml+Z5Jlti", + "1b/wLOslwKsYqtkoqFOZzDrjsV9f+iQWNrkA1l0jAqwS4K6fsdAlvriNeVMjW2pqwAe4QO1roniYFoRG", + "rsobW8hyK4H7eATGY1MJbstQJHCi9ycdkWVsKCaS2JswgGIL746FjTO8jua9PoEYrvBHGGak9Qqd6AJS", + "CjhdgdGOKS0bCrrBhTpdNuIWCjwnXNE1Fj5gBE+fA5Si4ClSQuHXAtfxhPtHhWKZtuBv3fCZZXRJ7Lcr", + "2DosUukR+bl0zxkfhgc4PeNfSrkLsUrQ+S4JDS17lYvzkzC6v3Sjb8aa385/IRcl49YtuFbslRbYP2AQ", + "raxUw9ps2uOFFFcQRxxHowWYIz3aOLa+G3fHlZhZZ/SVl8IGvP3GpK/mddqqHDDkKZf9COWQlbJAv5/c", + "ipTBYPsDVTP1phBsL8jYNBSCHqSBeWW/z86QZzjW0QwUFT54Q+7bAueVK6ZD7KPSXm2m2B789tdDPy9U", + "rbjfH6gKBwTw1vlZm+W4199ok/YssulPCnVFmfRx5FI5w3v+KWzQDpS3FIojECp4OPjn3Nsdi74p9g33", + "Wd+XJUvXyH3abSHi86II8wpMYrilTzRUayAHXguQrb7wCpOI5bJ4KkwvmXDvsXnjNcs1k+pfxENtuBPP", + "vZV1/Eqg5wveDjiVMGdDnlzZnCeiFAJ22Gc/q2xGG5FtmgG2Z2UmlMtmtXkaqPIxkI19nKoY8zjsP2mU", + "+pCNti4J4S9i+O7l6Ym61gj3QPSwG6p6yGwJXrEuXKKn4oISHBpdVhnbvGi5316b4YIIJpYRXcyPcytI", + "AIBIi3RxW4wIk5uaHEhnCos40mPRCwyIlAwVoHstgr77bReCmdwfUp339ICef8ozmUhd2JD8Rll9RYN/", + "ARQWUo0vbDGkuwraizlTWvV44TT8ybFUJBk36IvA9Rlgcujc+yfe5SgcbFf4tKuT23rFBCowOMioRCBh", + "A0LNdZt6sTqlvT7LcYjLF/1qy/J7mMn2d5p2kXNvvOG9Lp4j8HzxhL2TL6jmEk5jVhjJM/kp+sOra14q", + "5CFl8HN1KN1vJxfgpn6ugm08efrnzcA24ne6NC/tc+4nYcOp5kpphzV2q/zqso3jykvzRUeNGpr7+YTP", + "rFrVhZEFQJDFNOm1DrJlpyvH2MrKzF918N+Ap8R7/N53HGnDvOhfoU8rLRO3PHFYPmzEWFpnAlO+8wcB", + "PYWqyGueSdp7pbMxnMIqIYCQDwmXKECZka5xbCyFgZA9qgOYm+o4R8sl5rguAhtdZFbN5lBM+LXU3kWZ", + "AOOXd0sscb/g7l1Ei4v1iohzLhREtiFOhNGqhoNqNI3NSVf+UAUOvzfLzY/4g9CFVtms+c+xbwEDtumx", + "+dqV+Mmm9+d71a2OYvmavDEk9pveHDYWKLwLhyLYsaRKjOBwuGy60RPToQD0IvhejTSbzvxzEry6yqQw", + "WTufIjbz4f1b9Ojg4Gg4kn9VAjTL9SKOHRtbPrln0XJsOrvrFx7NL+WXhard1/V5ptO1tygQbYAMXK2z", + "rrcZQF8ZA2qYYnYtMvBbqi9h+KyeLPglgN5Kly0h0YQ/h0NyvYWWxIyxuFi6sH5JG7+30UJ3O44Pd5Rq", + "wRwf3okk30iV6puduxPaxc/dQc/m1KHsZpy+mhRUlq9Lkr1cZeza6Ctz3O6aStnWwtKreDGrivXxw42d", + "NtKJSEm/nQO6/CRcq4EJpDahwW2Z6b+Ao4Tpz6SqHbq6OcH42/HpSafbuRbGYncO+0/6h3C+yYXiuew8", + "63zbP+x/S5QuMJCDAFFyMMr4OCTZJA1ZNu+EGQuAG4EnUUfFrbQQztNK2C4r8pQ7weY+2gByci05s0Uu", + "DCT6p10MYACBX6GczGDm4tOvxDXIGBt04EJESTUedACPMJNKQAb/EG4VvJMx0iYwycEVL6HxwEHdryHm", + "XaTgnrhkElp5A+PHpRDWvdDpDM/Qca+vwC8e/MuiL1s6rXO7QZjNOdMXhoRz6DSbwrQSD9U/B51e70pq", + "e4VIGL1eKq231L1xXgw6H/e3B6/ADjWLVfkc7QcBCAnaeXp42JAQCP3H9UZnLQ6NFnue3+5Lt3OEX2rS", + "8NjiwQsedBIZNr90O9+t8x6AACue0VvAyDedcjPzB3mUy9jFjBcqmdAi+M5Tnzvdzm0v3kT0ypvH8nbQ", + "f7iU71z7w71YrTeFFab0hEsmOSCGNdIKBp+asTK1JtbZDHn8MzDJdQdqpUKxzfVpoDZVqJfCAJdvmAU2", + "5YqP0U+/oltlNTI8kDSRnLPIVngmnLcetjtQgHbfA7JXkcYv4jji94Ogwj758tXpQYDE02ofTgvDTCdX", + "Ih0oSBgIc7lS90/DMm6v/utHItZZ/D77MQAQ0Z/8ac4O1B7B3FAs86XWV1JYmsdBB/PgKgcquBjGL+Cv", + "/YE6E4IFKlWQZFH2pD/WepyJKNgHmHwaQbrC71T5hDA/fvwvuJXJceEmP18L84Nz+WsoaU/DHDR2GFwV", + "/7D9kI8NT4WNb9G2+47fvoy38faU0Oo7z7592u2c6rzI7XGW6RuRvtHmg/GuxD87DTSxnY9f7sryBVl5", + "tMZvXuz8WHaxgcgP2qsTi+baNgVUkUoUSNYMm3q7UnJpfioZOLFD4tYZnsCV4BT5QQdqXYLQPvsZag3M", + "rOTvrNCaQo4MkZmmTFaS4bwKDtTLV6cxI47mxZu+MIddYsZ2EyENM97ETkXYTgxky1jM3PDaMyrA9sFX", + "wGyiTWE4M9QZvBRHBCjyqXLw6jA2iS0p72CWfCcITQfJIwP1ukLaikdFEO0GK2OdzLIG4LmwY/g+hy3C", + "bzU8lUpYu5FrhStd9mmpZZ0GutoDL169kDVTGtdlZ4JW6tu1FPxJU+VvnEgUbVEnv00fWJsX2Hhr+tQg", + "7btrdI+rtBfsw+7a3W1Q7bWpf7sDZYWLSle2gOon1RbHjyj7A/U7Hj/mdeRYpe+jDX7E2tJt2A5xiuNM", + "Qkybp7OvQZVWaA/b49ZLr92v7pJxiGsrF12GHwzDMaFZiV4H/D9v3VMI2mWE3xg+wTB70WIitpVqnAl/", + "+Af2zj47pr/SRuS74D3iMs6czWj7mugsDaX9t0lWWHktmPegu8xqpjSVY8CdAYuya1nCFSYZZIJfC9h7", + "QqmSdTq3IQtgJI11RMnOQ8ITLQ2TES8X839wUEQf3x+owPFaWEhH95tSMiFW7lQg8pDfKMtEHgCVQSBo", + "39qVmEHAJUzXQIUdPecz/xVKDWVGFyrtOSNz5k8fKkHsAwHAmCqV1zIteEafaVLkF3CWoNU5Domj254k", + "lqauLbYUYau29Gfhky2c9A+pnVERGGhMowJUZbpdEUOOb10PgRn0AsSlqo31lQUaI0gKuqcFLRvYdR3f", + "oeCjFkW9f9AlPJOQLunXENUS5jz0sSVVaNNFxKjqgd9O2tfxveDpy0oEtmk672o9sRFy8nE55wIA4RlG", + "TcJeuKB5O0+/HzQml8X6uoZg9JbzDTHu9gmvB9nvSXmaI/nbKhBE7wOrhtPlJH09NvEXvFgICXd3saDI", + "pNG6jrGc/p6WcKFcf/3Vu5P2K7QBTZqKlf7XMjCbx6DPVyMSP8iUAIz1TZ0bZSM5SA0fL26G8ynUgMCs", + "UgSdCEZ9WDinVTemwnr3MoQRuO+XcZh7CmUqCpkxoHYT+juW1wIpJ8i/zgS3AhzAQPdhGY9O8D9vu2z2", + "sYolkXNpGs9Xr0JC7z3Jbvz+rpbHf+gr2bKhKyUtDS4TZ4R1sJFIjYVDibrIiTmo3cx8L1yNY+g+t+hm", + "MqNm7YerdpyKOIi7mObvhavd5pN7hOYmtHQnHpLXtlVebiRDuidFWSBb2s3HpWnyI3tYZXkXOH5qyxd2", + "5gjXUdoqeydLCsQNF1ditsJUh0L42BGoRwCzXClciGAieOVUotpU6CIGqokEAisWgaggN2IiFMYPFtkm", + "uswKMVC+M82MEYy78kZqLF1/ZIRIhb1yOu9rMz649f/JjXb64PbJE/xHnnGpDvBjqRj1J7hlUHXhRCtt", + "bLWChRKMwngtKyxBZSQ0FQCKYikeicuk08bLQ6IwuSd9mWdI2VZdYEFBWr4mjwXdiGrUDeTyLjSjUqLb", + "auzO+ZU4q5by3otbuwDE9oUWcemmBkVyBzkCB5YtxWqyIdxoNWQQLexdZQew8u5BVzzCdrBygULm367r", + "rbOs3QwiBh27Jpw2xAE90N46BOw4/5urOKIVY113aWsR0xqTD/mqNRA4DL9KxTI9Bog4J5Mry/aUdgRQ", + "SJA1pYjF/GjA2rjmZvacuQLinVMoKqvCjkL5GECSlEPBu/+ASQcIdhQFpryTbg02laqf4AqmFhzei98A", + "f71sYB/TtCAeh3VTAecgGNPLUCaHkZ5ez4hccMd+Yr0e1p8dMrzQwVMDXulcNtnYswAFd0/6WQEn3Na+", + "knh9JcE27EzpjuDycOfd97v0KEOZe4t5peLUe1q4+drXnYI9WHD51WyMfmwY3Nlpmahsvt0qlnxk4faP", + "+f9gZf4sZhYD9xTh49QLnaWC7TuUSgG0PcxFf6BOjR7JzBtPpZWY5m5WSeaKP9HFLGV5xAtEi3fZ8frQ", + "Op0fQK8Q7GegEOa7hGHqM6KmA6wl7DNQ7xUmw5+oa4DWNYO6HCjvHajL2PJFpnl6AZU6Yjz7aw7lPRdJ", + "ml8C6o5zvtNY0vPy1Wm4dwc8A28g5/JKqDC+vG4CngM/KY1zos3ctPTZ65iy0aOUjWoTKvW9GKhKN0ZY", + "HgpFqRnUvPqO+6kM15W+LwTUApXPYcGgMvi3QpiZlwE+FQ74qQcqlL0OZ0xn3h/GIv9QrW8EZP7VUZWw", + "qbCN9QcqStplXJRLlkqb0w7C2VBY1xOjkTa1xBZMeqlLHUaHKBmlnF0qVkS4cj4WDMFUXvil8ApkqYzI", + "TElWnWaX4bBxSfS6XMH4HZvpgqV6oPw+rYRI++zYsUxwf6ZR4XoFM+H841D5OYxrHiHI5mEbrPMHC1N4", + "3fEHIWnxdvdZRRDKNe7CHEHEjBQFNmKSopBGgWsXIYQQP2GgnOHKhiPNMyZHjMO1pimzJ31vQGZ8q9xk", + "3pEprSADSDUxGonEBdyvKQedxzMcVrEnokyCguLUp7e3dNebG53zsXehwCB4bcLSIe0dDyu8pDnBLstE", + "jf+4RCijAxr4JdxlU2l2RPMjCes5I8dj4V3fgcKZRcsV7VLdaDXmfwYb8zLay24naoCF8p2WRIeq/geb", + "QWKkAbOUXVJHLwPNove4WKH8KZSSRBe0BeQ32ipv0umuuxSLPrusmiaySxYNU9UaaMNEJsfSi2lj9ptK", + "wVLYZlOBwOPkakLNYOdZB2xEqCp81mmxnQCTFPbxklerTC2MPCXxl3JITUXGH3fKhJkrh4dc14tqzvZc", + "AtP5m96fCZSpnpDLpjxn/+///F80nlZMuXIyATrC0+Pzlz+wxZTwZvZAeuqipT6g0gNMU2WXnweYuz/o", + "PKuWB3z8crlmh3BTaeoNKds63Zh6iw0efnPEYpGx+JLtAWL5AeKVHwiX9ANoIjJ3BoyGRbVGlAq0e4GV", + "V0bkILI70UsowfvqubglQifT3lzROY9I3BrIQKoJrG3K/0nmFq4cQu+hhDMpoBC/oquAW4zDKMFGlibW", + "7ddYOeuiG4qcq8iEx71fee/TYe8v/Yvex89Puk+/+64ZZ/mTzC/8xrFeKKFeNhLfJc1vKqiaxxOnfeqC", + "9qnF2XTc9D9ZFzayqrVDh8LC9F7WUgKhgoKg7mgjJFNg+4w2mZg+jIjPQD0qlDOymg1PL4OXehAYO8NJ", + "2IrMv78HNhTzx9kl5a4fnMa6b3u5jyCGl37e8otSJS6RTASMKC43ZViFwUJyLtHiWu9awAM3hue5MKzS", + "nxqOUNtyEZtEc6Hlh/dv42UxOVdizrUSKzyl4Ch1WQZ4BF6pEo665tjTw6M/I2NRt1Q9v4AJVLCgqwg2", + "ghYAezHMRAvDZH0ulxxdSsymMINwVVi+i/ChRuaY/DAnk1Eq9rznEoH5qVwSWGbFLWrkSsDPr+rCuu4x", + "o718Xgb7oxRECJDa8be/y/n36PAvq9/zHcxksnBqvpvkm3mfLpyyW+dJhKMT2vJYqJSyfMJhiqsH9GM8", + "H8PhpTwbQ0iMzsx1rz/PCrsw93i7uVbOaGV/jsVlDVVMtO/e12XE4tb+e8s8tR5w+haX8wNlZdCE1Zfh", + "wWR655Kd5uGsKTwje5AYwZ24iKzUIEhFU5ojPBhx9O8r17HeykbC9GQZ7D+O8yuK5OFIGYdy6bQyreuu", + "HKLar7Fyr+DB+145bOWUu8nOqS5x0XCI6W7aebT6vZ+0e6MLld5hjgz0nPFdVjb440sW9Q263V/3egIx", + "zB9gKemMs/YqEv+E19CLTxIA98fCNVFyuMIAftuvJ6csnloqp51wiIkQ6SXNSxCv/mJqG7X/SppfZb4q", + "dBXZcOIX0Vt2Op5KvBMTBtUW86EksLqUVAM+Kwl0Pm7kJNC87nQP7mc9jDEyDYDoVSf4MUouLVbVDPlz", + "CwpaOHpvK9HWpWuIdDjH7zluKof5aUg5AZ/af2t/qeQP1BLRZ79alzI9GgljmZVjJUcy4YDMSYDGoUHy", + "xQcqFdWf/L+5wdPsJ5lT8IgnEymufU+Gws1/BRStOaW0ond+jh6L4nUXgEgqw4W8qD77QY4nwuD/2QAb", + "zewUQC7L0MqwcMzxK8EyrcbC9Aeqhyth3TP2b7/a+An2pMsIDtEvrEjZ3r+/PTzsfXd4yN69OLD7/kUK", + "Eddf/LbLhjzjKvEunX/zAFaA7f37yXeVd3Hh6q/+ZzesZ3jlu8Pen2svLXTzSRd+jW88PewdxTdaVqQi", + "LRfwmZbAd/hXGfimqQJgvvA37DL8w7rWKPj6dpO0dyfDeT4Xo/svYjznQpMbGFAILwWAJjKcdePhfSUg", + "sF3XaoCtoIkHA6pN3Sn4GnbpzTzPOAcNIge+pFQorDsf3B9EsL4XrjoCxoeYALCwehsIViatg/OCbZWs", + "t9ICfaTdckN6nLJUjrpBmMqDZoZwGo9QmvwA8Z4CS723kZ6pvm4/aL7T13AKvMe8/7s4ZEKefRnceYQr", + "CSOAC364F9zNIACobAggNNqD94KnFD5YzxxAd4Jr6r//tVgEnTjheshpsrNPAxtMY63tIxMnqOytXYFu", + "ID5W4HZyUSHEbbUQi7zE91cI2kKAvDVQV4Xvl8o2H+FSnwm3aCyqXMYHwJVsJxAGWlcG8Ga6PUUUQNVs", + "5QKbUEa0KbOxcGOiaicjpprsCJYk91vgboKbcmdZPdEzakmdSIV1FytYov0zUtGlHVlBAnEl13sdfuhu", + "Z9ssC4o+ll1dnWbR8IVt4T2fNId1K/BXj9xcNgD9jEgMN1OYEOpdin/FIcyEmZoVFDtJaX0hOWEu4DUn", + "gW3qg9HeO1OeTZUjrVJtV0C8yuwWvZ6m3FFO0jKN2VL0f5V5HfeNhvmHUQNexWKbE9EtNIKCTStUYtNQ", + "cZvmDNRq1VkdMq5FiAdqLkTcjtVGMd87U7/WDLlzgKSvR+DCNrRGTtiDqXVzBlcbt9dP6ydxEQI+9c33", + "eQ/Y4Lw49XrwTK98b7+/GeVeGe27B4NyTHP4Bzcq8+K6tWG5mQfImzuROG7cG/sLPHVPZ5FKE5tnqWyJ", + "GA/DbiSO+KDkb4Voooko9faGpmOtbMV5GleXTNhdwxY/kDjiYKphfQIOVOON/D2Yz4PPYVG+EHOmQMyr", + "eYnUeSmQcwEXCKJQ1IRiKHGll8VRVodNjpoYeXEpMRn+kS+ln1aUa8ig3ipUNr+MByWzb2Pg7AwCTW/s", + "62sKqvxuqzkfBHPi1mFvG6Nfq+5YzuAQTpT+DfAAJbW+HlVO7VTN3el2JoKnMOrPnf/dOzt73SNAu955", + "I8v1O5FKThSgI+CuB1ZvKg7fmzeE+7X70nA3umAuG65CvzxGQYaJXphlQsgKpnttmTZyVQIZ4MStEwB+", + "VXEC+UIw+HfMR/i55NHNBJvqVLA9nTieMXyny4D44E9HR/t14vY/HR21dXOK/KON3frnYe8/P37+tnvU", + "VDKzvOrsDsPTW0ZmIkrhY9+sIcTm9+eQL7tJGl6mx/agnPrmi1E9tqh+LbZ8TmSID3GZbAdjRUpQwog3", + "cqQ2NzPSWaZvmnNGajSXFV7BeUGIxe1QKyVHDPvOpA1obUtUt31n2qSdytibWysfuCCess6D7Ypv9XjN", + "7dAL1le9AzbtLr7TWF99dvZ6XRXKMz67MVieiXDLawCTm6F0hpsZO41vs8QbbLijHhlhA5w0FWFDCRof", + "c6msq7HbmUIBlr7SimU64dlEW/fsL0+fPsXadvjqhFvGwcx5c/9Nzsfimy77hr7r/0mf+wZr0L65EcNp", + "kn8zUIEv3fbDj/1MWgcI9Hv73xBIv612qXcjU8GQZg7YFZlVPLcT7bqYWxg+BJyvwn9qzz/2Xoy6iJP+", + "ty77zIgi+Ewkf2Nf9r9BhlhgNSF62CozLHMTo4vxhIrrp36AltmZSiZGK13Y0J/j05N++DdtTyXyg//I", + "DeP096qBh9jaQCEh80udii5MbJeVlM0nhM8/1CkxxBNnvAE+7pCDBOtRLi2A+PspFOlAERhE0N/+QA0U", + "LpZ/bug3Dkh7pPJ5ZFw0ogSVw9rBPjtRdNnUI/JDqrOCamCAhMeJGmkjxga/y4dUWBwvp+oEilh8qHOh", + "uqFGEZu2EdUBezRQecm6GCDrrcgCozz8Li0rVIw/9ktZBPb4iWAvsPGXJPpQ8alvFHU8fg6oM9nJiNrv", + "4YyAE4ZZqIkgmj2IroyFo4Nst7IIZR0/hEZBwir8MVilGlSkTz0F8XeaugIQftjVgKEhbr0dki6bNQVD", + "yRSU+v8SPa37iMUstPVANWgN/QCq+sbiwqgmXyOgfjkEQFM6g56jbjcY6TVhh2h7h22kPXZ3ik/5ntwb", + "dGJs4YEEpdaDNhEpCTMMPfNVMC0kejoFCKVy88nWDrcEEbA5v1ErZeAMnrpXIYAmHlYKqAttYgB/fmDc", + "uMXV5zst/2f6B4TbrmQdnrFRFH6UgPO3OtRWfnnpCS0evYtCpruc7rdacj+arxLM/ucfH2X6kzdHcqx4", + "hmBK4RS5vUwinsxKqXyPj/1h5BLH89+SeXc5mABLxNnp+T96Q8TYugvxxHNaa0QrbCz41O8tnfe8W+Kg", + "mjZK+sujLAihBWA2rNkuwpHKNXwreOoPY7lgOA/sx2EX2vy4FzPgscOo/KMNxJf7K7MkQTtJqi7cqvh8", + "Ob26cEsD9Q9k03YIOMex+dfWDD2H+deFywsHEahMjgSi3v733ey93c1W5F4XbuM4uhEJoMaPD8ockWYL", + "jYAT78Pz94rvEVtZzUEwX+FPLz4csscDAS9FPJDciGsJ51+GiytSdi1ToTe6oqzIBVUct1rCUJJcFY2l", + "V/cnZTpYrM0OyxagyZyO2AJdxi3LOSTbOs0qXYPML4qf66nfwogooAR2nv+utPG77RCxYHGbL99579Nx", + "79fD3l96H//n/9jKLsNaHEzzo52Lwkphp5WtWdf4194bqaSdiLR33HA5di6nwjo+zf1aAPZjfUFG9HKf", + "fV9ww5UTuAxDwd6/efntt9/+pb/8VrbWlTPM1duqJ5Tnt21HfFeeHj5dZjMAdlVmGZMAWT02wtouy4HW", + "jTkzwygzYlLXp/s9aNPxyP9hkWyhGI8ReQDY5YD8WyqG3D5VcnozQ+0pBxEzgZ80ZAJ/ecTwBUj2YEFF", + "BSS434mxyiRuXa215rjYftV2dL1jzday3Sy0hrgBC4VQCxr9lhioTOzlnRVj8yyrfHbjiZ1yc9V+w47j", + "tIwzb0FTRrjuCmWdMuDLC9T4WcCoH0kFqK0oE9xcCRM4aP6F140ylE6Qc/nu9MjvCcmE506Y8M5i4dE7", + "bq7u22GptXGPKdcb9KHtrPcO5ikq2n8Z1+g4TaNkoqzQfb5UvWDmS5ncXDcQD3552v99i2G9kaVu85Nl", + "WyBtso8QeRRmIBJ1VW3MzyqbEU9AGGYuDDt5xRKukJ1qLK0TBqGhOVit/jZyoPNlYqDz+5eCShvbn50o", + "Df9hSaGczusO4LoLYhOeCac/CaMPUmn5MFvODIzBBN/U398h5Lb/AkC9aea/0vUCwk2aQXxjxH44Pz9l", + "zvDRSCbMnylcn73kWRbQ4Y5PT5AHSVr/yRvvUd7wK8GkY0OR8MIK9kHJK8NHDv/KC6enPDC9wbNIdhlJ", + "gELd7d/fNYK74TDP/MjP9a/C6M46RRfwfM/pnh8lo7lK72T5TlIxzbVD146+DPMqwqxWpqi/zdIKtXxl", + "3wvrtBGWYOGx8TjYSF5S9qLrfSR9AwcBmO96d9H3h3OJTDOBS47vxsPK398xpQleDkh4LJ1QJiJLGfcL", + "25iVpHZfPZyOe1g8/PDuaxcfWQnPWKUXjm/VoaT7LDx8dHjE5KjyHLIJlTQBjTSo3wt3Hvtzj0H42MiZ", + "467xBvG8eYDbOlmLXM0t319j1boldvuc0eSGCBcRnQSXrHWpYP+lFqSwlRQ9ZgUeE0p8Pszk9O4/Zgym", + "z0Nop/qJksRLmigrVjgn1dhuJBzsDN9i4lpUu+5lPswK1Bajfj1jI55ZwZJMcGMDGGhltE2cu34W6+J2", + "91s/ZW7GZqqQ87/fpdPW8v6IcW4I8n43RSuaOGCFW6FZQc6fHj6py/kNR0GvBINLmX8e88efHh7696Tz", + "L3hVyEQSEqR17npSPWO8dEEm3JEe+K9X9XGPzxFJYPav0m6C0Vd0YEwhgDaQdC2oV/A89lvV6nnIoGYy", + "7k207W5m+E8L93Ca+NVr3l0GJbbvkBUPm1V6ttu2WXN2KmW7zW7qCQS5sMDCP1oGu8ou4C1rl405VRwA", + "wAWl/s91tGoUDlEL4Wlr5ViJlAl1LTKdi9JppWYt42m4Q3l6eNTw95HM8JC8p3RoPtyrUFk/PPuNLVVb", + "2lK7QfWPDg+993jNM5nWKECbtXWYSVvunXgXfU8pG9gWNPFAKRvlOGmRGhOwYTly7K035nFFE24CG1i5", + "3siPnYg+6nfDOQI/yJNE5CBehStXermsPcc9JnRlBw6mOs0+fnANldhcHReyOuaLeQXgwwMxZT3BoWwb", + "VbrPXvNkwkaGT7HUi0p7puxSps/YZyt++zIYqJQ7/ox9DovU8xLhfx8M1KXfcXF1iBUskp4nwtreVCvt", + "tJIJZFPkwlgI5CdGWztnMgkm4jnj7C23rgdr2jt5hfEM4IglT8C/qMpdHvSQGDxtMQ0hDBx2n70yOsdO", + "YSYrisSY5za47ZcyvcTCK2BspYiNkNcixb9Ji7hlbsIVe8L4RPA03Ptmvq9WCAWPdkNix40w3pRICP7D", + "CKCsoxiNhOmzl5mEp+xEF1nKnAEm0IWvwRWycCJx0N8+ewP1feXwbfBR5qYMuURjs+XpgpbKLwaUlloh", + "gCYHe/0c7qjZ5d+MyDM++yvPsktEAap9TmcpQLbDAcbbY5Jw6wRPsXTtRvr5nvBchLKssVDCyIRd1i3h", + "ZZ+90SZ6XjR7go5LpLs/AgkhVuexPf/4DChqvbQh9T1nqU6KqVD+rUs3y8Xlfrdmzi+RvdDLnDbTCAJX", + "UmuSz/Mf0K1X8DAatW5ZNDac0ccbOfNB4OrDW4kJ/d6LbOAFBAfR1vWpJLe1QqXssGE9wvIGovl1dbLL", + "rK4r1jXPCqw7nAqvZsaIBJC7sCnu8Fqsz875lbD+vUSk0BAk7Vyi3FzixjvUblLl5IbmvEHihdM9I0iM", + "y+YywRUQCYMg4SViDz/pV2giLUCvl7wAeHtdJj3UlGCzQutTEPxNBL7P3gODBag0S7w94Y49OXx69JxI", + "u0mYecUSQH1PYUY8EQh5P5LGOlT2MdThG7Iy/Vb6A5yR5jyxLNuOwWCHTLu1dvy3a2xGj67qe34EfkXP", + "hLkWpnfm9TFagLU2eKxtPsBq5WXONlYzg22DcuYM76MrNc0RrAotqNbZhRGjPvtJq55XPlsMvU1JRZJx", + "g+Sbvh8D5R8NdcXRJglpApcuaLPOi4y7ABvIFePEAn8B30VnGtOt++xnN4G7Sp3ZgYLcEcRdccJMJVFx", + "F5nrEikr2HL0DkrGRijTrcbXnPT2AsD9oY9lLfVADcVYKiIvBupvU1YbFy7RU3FRqCulb5A7Ugl8wkW3", + "EwrPa7cZTcYelwHrvc+1zu7Jo8cGsLGaR7+IWGj4DZJG16KQ0rJMTqXDRKMn7J18gcyYR+xH+cKrTfS9", + "vQkHz4ayeVKRaLh97PyeB4hyxPj6kkrfQDUd/X7kxw9vIgieBAEshZYDXX5FC8Df1aYsh18Qzv7jiOj5", + "t47ubR3e4BG3aSHKh9hQjLnqwgFMOot5MUHvWALOrj9vDQXTQ9BOf+KqX9yArAPimE28SIq0CgmxZkU0", + "GVOwO2twaQWcCa8PCg1ipU3L+DWXGcTTyThVarTR4Y1fCOTSpTGNF/PDGUUNdS4UQDAA7MOMielQpKlI", + "A9AqWFY4nGgLyAgWEB1+VoSIbcAZATnrBucYXBZ/TlHSaRMOQWUf+dBCcwGTAbw5b895ZgRPZwMFvcKk", + "JRgCwVL45rvhNfxpob/+VZFiViFyfRusyZq3o+c4nQYxy6+BTp5iNH4+tCm/jadS6GZlDt1ETFmSaes3", + "ABMpla3fhGYwS3CkBSMeeb65YjrnvxUi7oVEBB72SQvS2rKddhnPtBoTksciwYyD846fnq4fBE5RJB5u", + "Gk9u9LX0v0mHEBHwa+gbQEmgGxA3JLyq9Z58qkGDFKWOTrnyh6JwfkrFsBiP/epXD4n+8FAMvegP/ecG", + "qn7carldLDc327l3gw/NLCXXpkNOEzxM/yHTQclMzRmMBTOxjuH68uX/BwAA//+6C6LI3HQDAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index 5943937c9..0244eefb1 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1959,7 +1959,7 @@ components: BrowserTargetType: type: string description: CDP target type of the page that produced the event. - enum: [page, background_page, service_worker, shared_worker, other] + enum: [page, iframe, worker, background_page, service_worker, shared_worker, other] BrowserEventContext: type: object description: > @@ -2926,7 +2926,7 @@ components: properties: step: type: string - description: The CDP method or initialization step that failed (e.g. Target.setAutoAttach). + description: The CDP method or initialization step that failed (e.g. browsersurface.Start). BrowserMonitorInitFailedEvent: type: object description: The CDP session could not be initialized. From 888fd0d67ad0ec61abd2520c1173ae83e2e9bcc2 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:06:13 +0000 Subject: [PATCH 3/5] Preserve independent worker session lifetimes --- server/lib/browsersurface/frames.go | 16 ++++++-- .../browsersurface/worker_lifetime_test.go | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 server/lib/browsersurface/worker_lifetime_test.go diff --git a/server/lib/browsersurface/frames.go b/server/lib/browsersurface/frames.go index 6e3ee1790..6b6209bb2 100644 --- a/server/lib/browsersurface/frames.go +++ b/server/lib/browsersurface/frames.go @@ -7,6 +7,11 @@ import ( "time" ) +// Shared/service workers and background pages can outlive their creating tab. +func inheritsParentLifetime(targetType string) bool { + return targetType == "iframe" || targetType == "worker" +} + func (t *Tracker) addSession(sessionID, parentSessionID string, target targetInfo) { if !t.tracksTarget(target.Type) { return @@ -16,7 +21,7 @@ func (t *Tracker) addSession(sessionID, parentSessionID string, target targetInf t.stateMu.Unlock() return } - if target.Type != "page" && parentSessionID != "" && t.sessions[parentSessionID] == nil { + if inheritsParentLifetime(target.Type) && parentSessionID != "" && t.sessions[parentSessionID] == nil { t.stateMu.Unlock() return } @@ -37,7 +42,7 @@ func (t *Tracker) addSession(sessionID, parentSessionID string, target targetInf } } ownedParentID := "" - if target.Type != "page" { + if inheritsParentLifetime(target.Type) { ownedParentID = parentSessionID } t.sessions[sessionID] = &session{id: sessionID, parentID: ownedParentID, target: target, tabID: tabID} @@ -205,9 +210,12 @@ func (t *Tracker) bindSessionsLocked() { for changed := true; changed; { changed = false for _, sess := range t.sessions { + if sess.target.Type != "page" && !inheritsParentLifetime(sess.target.Type) { + continue + } // Explicit flat attachments have no parent session on the message. // Recover it from target identity even when frame tracking is disabled. - if sess.target.Type != "page" && sess.parentID == "" { + if inheritsParentLifetime(sess.target.Type) && sess.parentID == "" { for id, parent := range t.sessions { if id != sess.id && parent.target.TargetID == sess.target.ParentFrameID { sess.parentID = id @@ -221,7 +229,7 @@ func (t *Tracker) bindSessionsLocked() { } if tabID := t.tabsByTarget[sess.target.TargetID]; tabID != 0 { sess.tabID = tabID - } else if parent := t.sessions[sess.parentID]; parent != nil && parent.tabID != 0 { + } else if parent := t.sessions[sess.parentID]; parent != nil { sess.tabID = parent.tabID } else if parentFrame := t.frames[sess.target.ParentFrameID]; parentFrame != nil { sess.tabID = parentFrame.tabID diff --git a/server/lib/browsersurface/worker_lifetime_test.go b/server/lib/browsersurface/worker_lifetime_test.go new file mode 100644 index 000000000..415ac0582 --- /dev/null +++ b/server/lib/browsersurface/worker_lifetime_test.go @@ -0,0 +1,38 @@ +package browsersurface + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIndependentWorkersOutliveCreatingTab(t *testing.T) { + tracker := New(newFakeProtocol(), WithoutLocations(), + WithAdditionalTargets("worker", "shared_worker", "service_worker", "background_page")) + defer tracker.stop() + page := targetInfo{TargetID: "page-a", Type: "page"} + tracker.registerPage(page) + tracker.addSession("page", "", page) + for _, targetType := range []string{"iframe", "worker", "shared_worker", "service_worker", "background_page"} { + tracker.addSession(targetType, "page", targetInfo{ + TargetID: targetType, Type: targetType, ParentFrameID: "page-a", + }) + } + // A dedicated worker spawned by a shared worker follows that worker's + // lifetime, even if parentFrameId still identifies the original page. + tracker.addSession("shared-child", "shared_worker", targetInfo{ + TargetID: "shared-child", Type: "worker", ParentFrameID: "page-a", + }) + + tracker.removeTarget("page-a") + for _, sessionID := range []string{"page", "iframe", "worker"} { + require.False(t, tracker.SessionExists(sessionID), sessionID) + } + for _, sessionID := range []string{"shared_worker", "service_worker", "background_page", "shared-child"} { + require.True(t, tracker.SessionExists(sessionID), sessionID) + } + tracker.removeTarget("shared_worker") + require.False(t, tracker.SessionExists("shared_worker")) + require.False(t, tracker.SessionExists("shared-child")) + require.True(t, tracker.SessionExists("service_worker")) +} From ed108ad81fc0230be8614492dd95b3a87656d61f Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:40:41 +0000 Subject: [PATCH 4/5] Preserve surface metadata and default iframe lifecycles --- server/lib/browsersurface/frames.go | 8 +- .../iframe_initialization_test.go | 34 ++++++ server/lib/browsersurface/types.go | 11 +- server/lib/cdpmonitor/handlers.go | 20 ++-- server/lib/cdpmonitor/monitor.go | 10 ++ server/lib/cdpmonitor/monitor_test.go | 22 ++++ .../lib/cdpmonitor/reconnect_capture_test.go | 111 ++++++++++++++++++ server/lib/cdpmonitor/surface_events_test.go | 76 ++++++++++++ server/lib/cdpmonitor/types.go | 7 +- 9 files changed, 274 insertions(+), 25 deletions(-) create mode 100644 server/lib/browsersurface/iframe_initialization_test.go create mode 100644 server/lib/cdpmonitor/reconnect_capture_test.go create mode 100644 server/lib/cdpmonitor/surface_events_test.go diff --git a/server/lib/browsersurface/frames.go b/server/lib/browsersurface/frames.go index 6b6209bb2..00641d63e 100644 --- a/server/lib/browsersurface/frames.go +++ b/server/lib/browsersurface/frames.go @@ -51,7 +51,7 @@ func (t *Tracker) addSession(sessionID, parentSessionID string, target targetInf t.signalChanged() t.publish(Event{ Kind: EventSessionAttached, SessionID: sessionID, - Target: SessionTarget{ID: target.TargetID, Type: target.Type, URL: target.URL, Title: target.Title, OpenerID: target.OpenerID}, + Target: SessionTarget{ID: target.TargetID, Type: target.Type, URL: target.URL, Title: target.Title, OpenerID: target.OpenerID, ParentFrameID: target.ParentFrameID}, }) if t.tracksTarget("worker") && target.Type != "service_worker" { go t.attachDedicatedWorkers(sessionID) @@ -213,9 +213,9 @@ func (t *Tracker) bindSessionsLocked() { if sess.target.Type != "page" && !inheritsParentLifetime(sess.target.Type) { continue } - // Explicit flat attachments have no parent session on the message. - // Recover it from target identity even when frame tracking is disabled. - if inheritsParentLifetime(sess.target.Type) && sess.parentID == "" { + // Session-only tracking has no frame tree to associate descendants. + // Recover ownership without changing location-tracked initialization. + if !t.trackLocations && inheritsParentLifetime(sess.target.Type) && sess.parentID == "" { for id, parent := range t.sessions { if id != sess.id && parent.target.TargetID == sess.target.ParentFrameID { sess.parentID = id diff --git a/server/lib/browsersurface/iframe_initialization_test.go b/server/lib/browsersurface/iframe_initialization_test.go new file mode 100644 index 000000000..6f2c2e90f --- /dev/null +++ b/server/lib/browsersurface/iframe_initialization_test.go @@ -0,0 +1,34 @@ +package browsersurface + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestExplicitIframeInitializationDoesNotWaitForParent(t *testing.T) { + tracker := New(newFakeProtocol()) + defer tracker.stop() + page := targetInfo{TargetID: "page-a", Type: "page"} + tabID, _ := tracker.registerPage(page) + tracker.stateMu.Lock() + // The page's frame is known, but its session initialization is still pending. + tracker.sessions["session-a"] = &session{id: "session-a", target: page, tabID: tabID, initializing: true} + tracker.upsertFrameLocked(tabID, frameInfo{ID: "page-a"}) + tracker.stateMu.Unlock() + tracker.addSession("oopif-session", "", targetInfo{ + TargetID: "oopif", Type: "iframe", ParentFrameID: "page-a", + }) + defer tracker.removeSession("oopif-session") + + require.Eventually(t, func() bool { + tracker.stateMu.RLock() + defer tracker.stateMu.RUnlock() + sess := tracker.sessions["oopif-session"] + return sess != nil && sess.initialized + }, time.Second, 10*time.Millisecond) + + tracker.removeSession("session-a") + require.True(t, tracker.SessionExists("oopif-session"), "an explicitly attached iframe initializes independently") +} diff --git a/server/lib/browsersurface/types.go b/server/lib/browsersurface/types.go index 89e533240..1f667cc58 100644 --- a/server/lib/browsersurface/types.go +++ b/server/lib/browsersurface/types.go @@ -29,11 +29,12 @@ const ( ) type SessionTarget struct { - ID string - Type string - URL string - Title string - OpenerID string + ID string + Type string + URL string + Title string + OpenerID string + ParentFrameID string } type Event struct { diff --git a/server/lib/cdpmonitor/handlers.go b/server/lib/cdpmonitor/handlers.go index 67bc682b6..d347cf256 100644 --- a/server/lib/cdpmonitor/handlers.go +++ b/server/lib/cdpmonitor/handlers.go @@ -162,16 +162,6 @@ func (m *Monitor) dispatchEvent(msg cdpMessage) { if m.decodeParams(msg.Method, msg.Params, &p) { m.handleTimelineEvent(p, msg.SessionID) } - case "Target.attachedToTarget": - var p cdpTargetAttachedToTargetParams - if m.decodeParams(msg.Method, msg.Params, &p) { - m.handleAttachedToTarget(ctx, p) - } - case "Target.detachedFromTarget": - var p cdpTargetDetachedFromTargetParams - if m.decodeParams(msg.Method, msg.Params, &p) { - m.handleDetachedFromTarget(p) - } case "Inspector.targetCrashed": // No params; the crashed page is identified by the session it fires on. m.handleTargetCrashed(msg.SessionID) @@ -768,6 +758,9 @@ func (m *Monitor) handleFrameNavigated(p cdpPageFrameNavigatedParams, sessionID cs := m.computedStates[sessionID] m.sessionsMu.RUnlock() + if info.targetType == "iframe" && p.Frame.ParentID == "" { + p.Frame.ParentID = info.parentFrameID + } data, _ := json.Marshal(oapi.BrowserPageNavigationEventData{ SessionId: sessionID, TargetId: info.targetID, @@ -864,9 +857,10 @@ func (m *Monitor) handleAttachedToTarget(ctx context.Context, p cdpTargetAttache return } m.sessions[p.SessionID] = targetInfo{ - targetID: p.TargetInfo.TargetID, - url: p.TargetInfo.URL, - targetType: p.TargetInfo.Type, + targetID: p.TargetInfo.TargetID, + url: p.TargetInfo.URL, + targetType: p.TargetInfo.Type, + parentFrameID: p.TargetInfo.ParentFrameID, } if p.TargetInfo.Type == targetTypePage { m.computedStates[p.SessionID] = newComputedState(m.publish) diff --git a/server/lib/cdpmonitor/monitor.go b/server/lib/cdpmonitor/monitor.go index 574f4b908..e31939941 100644 --- a/server/lib/cdpmonitor/monitor.go +++ b/server/lib/cdpmonitor/monitor.go @@ -218,6 +218,7 @@ func (m *Monitor) handleSurfaceEvent(conn *monitorConnection, event browsersurfa TargetInfo: cdpTargetTargetInfo{ TargetID: event.Target.ID, Type: event.Target.Type, URL: event.Target.URL, Title: event.Target.Title, OpenerID: event.Target.OpenerID, + ParentFrameID: event.Target.ParentFrameID, }, }) case browsersurface.EventSessionRemoved: @@ -228,6 +229,15 @@ func (m *Monitor) handleSurfaceEvent(conn *monitorConnection, event browsersurfa if event.Message.Method == "Target.attachedToTarget" || event.Message.Method == "Target.detachedFromTarget" { return } + // Preserve crash reporting even if Chrome has already detached the session. + if event.Message.SessionID != "" && event.Message.Method != "Inspector.targetCrashed" { + m.sessionsMu.RLock() + _, tracked := m.sessions[event.Message.SessionID] + m.sessionsMu.RUnlock() + if !tracked { + return + } + } m.dispatchEvent(cdpMessage{ Method: event.Message.Method, Params: event.Message.Params, SessionID: event.Message.SessionID, }) diff --git a/server/lib/cdpmonitor/monitor_test.go b/server/lib/cdpmonitor/monitor_test.go index 5e9a22f42..09b1df6ac 100644 --- a/server/lib/cdpmonitor/monitor_test.go +++ b/server/lib/cdpmonitor/monitor_test.go @@ -223,6 +223,28 @@ func TestSurfaceDiscovery(t *testing.T) { m.sessionsMu.RUnlock() assert.Equal(t, "target-xyz", info.targetID) assert.Equal(t, "page", info.targetType) + srv.sendToMonitor(t, map[string]any{ + "method": "Target.attachedToTarget", + "params": map[string]any{ + "sessionId": "session-abc", + "targetInfo": map[string]any{"targetId": "target-xyz", "type": "page"}, + }, + }) + // Navigation is an ordered barrier after both attachment notifications. + srv.sendToMonitor(t, map[string]any{ + "method": "Page.frameNavigated", "sessionId": "session-abc", + "params": map[string]any{"frame": map[string]any{"id": "target-xyz", "url": "https://example.com/"}}, + }) + ec.waitFor(t, EventNavigation, time.Second) + ec.mu.Lock() + defer ec.mu.Unlock() + opened := 0 + for _, event := range ec.events { + if event.Type == EventTabOpened { + opened++ + } + } + require.Equal(t, 1, opened) } // TestInjectScriptEvaluatesCurrentDocument verifies that when a page target diff --git a/server/lib/cdpmonitor/reconnect_capture_test.go b/server/lib/cdpmonitor/reconnect_capture_test.go new file mode 100644 index 000000000..5a1e45d8f --- /dev/null +++ b/server/lib/cdpmonitor/reconnect_capture_test.go @@ -0,0 +1,111 @@ +package cdpmonitor + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/events" + "github.com/stretchr/testify/require" +) + +func TestReconnectDrainsPendingResponseCaptureBeforeOpeningConnection(t *testing.T) { + oldServer := newTestServer(t) + defer oldServer.close() + newServer := newTestServer(t) + defer newServer.close() + upstream := newTestUpstream(oldServer.wsURL()) + ec := newEventCollector() + publish := ec.publishFn() + publishingResponse := make(chan struct{}) + releaseResponse := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseResponse) }) } + m := New(upstream, func(event events.Event) (events.Envelope, bool) { + if event.Type == EventNetworkResponse { + close(publishingResponse) + <-releaseResponse + } + return publish(event) + }, 0, discardLogger, nil) + require.NoError(t, m.Start(context.Background())) + defer m.Stop() + defer release() + + requestedBody := make(chan struct{}) + stopResponders := make(chan struct{}) + defer close(stopResponders) + go listenAndRespond(oldServer, stopResponders, func(msg cdpMessage) any { + if msg.Method == "Network.getResponseBody" { + close(requestedBody) + // Leave the command unanswered until closing the connection cancels it. + return map[string]any{"method": "Test.pendingResponse"} + } + return nil + }) + go listenAndRespond(newServer, stopResponders, nil) + select { + case <-oldServer.connCh: + case <-time.After(time.Second): + t.Fatal("old connection was not accepted") + } + oldServer.sendToMonitor(t, map[string]any{ + "method": "Target.attachedToTarget", + "params": map[string]any{ + "sessionId": "old-session", "targetInfo": map[string]any{"targetId": "old-target", "type": "page"}, + }, + }) + ec.waitFor(t, EventTabOpened, time.Second) + for _, message := range []map[string]any{ + {"method": "Network.requestWillBeSent", "params": map[string]any{ + "requestId": "request", "type": "Fetch", "request": map[string]any{"method": "GET", "url": "https://example.com/"}, + }}, + {"method": "Network.responseReceived", "params": map[string]any{ + "requestId": "request", "response": map[string]any{"status": 200, "mimeType": "text/plain"}, + }}, + {"method": "Network.loadingFinished", "params": map[string]any{"requestId": "request"}}, + } { + message["sessionId"] = "old-session" + oldServer.sendToMonitor(t, message) + } + select { + case <-requestedBody: + case <-time.After(time.Second): + t.Fatal("response capture did not start") + } + upstream.notifyRestart(newServer.wsURL()) + select { + case <-publishingResponse: + case <-time.After(time.Second): + t.Fatal("restart did not cancel the pending body command") + } + require.Never(t, func() bool { + select { + case <-newServer.connCh: + return true + default: + return false + } + }, 200*time.Millisecond, 10*time.Millisecond, "new connection opened before old capture drained") + release() + ec.waitFor(t, EventMonitorReconnected, 3*time.Second) + m.sessionsMu.RLock() + _, oldSession := m.sessions["old-session"] + m.sessionsMu.RUnlock() + require.False(t, oldSession) + + ec.mu.Lock() + defer ec.mu.Unlock() + responseIndex, reconnectedIndex := -1, -1 + for i, event := range ec.events { + if event.Type == EventNetworkResponse { + responseIndex = i + } + if event.Type == EventMonitorReconnected { + reconnectedIndex = i + } + } + require.GreaterOrEqual(t, responseIndex, 0) + require.Greater(t, reconnectedIndex, responseIndex) +} diff --git a/server/lib/cdpmonitor/surface_events_test.go b/server/lib/cdpmonitor/surface_events_test.go new file mode 100644 index 000000000..72cf925cf --- /dev/null +++ b/server/lib/cdpmonitor/surface_events_test.go @@ -0,0 +1,76 @@ +package cdpmonitor + +import ( + "encoding/json" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/browsersurface" + "github.com/kernel/kernel-images/server/lib/cdpclient" + "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +func TestSurfaceDropsNetworkEventsForUnknownAndRemovedSessions(t *testing.T) { + m, ec := newComputedMonitor(t) + defer m.Stop() + params, err := json.Marshal(cdpNetworkRequestWillBeSentParams{ + RequestID: "request", Request: cdpNetworkRequest{Method: "GET", URL: "https://example.com/"}, + }) + require.NoError(t, err) + event := browsersurface.Event{Kind: browsersurface.EventProtocol, Message: cdpclient.Message{ + Method: "Network.requestWillBeSent", SessionID: "session", Params: params, + }} + m.handleSurfaceEvent(nil, event) + require.Empty(t, m.pendingRequests) + require.Empty(t, ec.events) + + m.sessions["session"] = targetInfo{targetID: "frame", targetType: "iframe"} + m.handleSurfaceEvent(nil, event) + require.Len(t, m.pendingRequests, 1) + require.Len(t, ec.events, 1) + + m.handleSurfaceEvent(nil, browsersurface.Event{Kind: browsersurface.EventSessionRemoved, SessionID: "session"}) + m.handleSurfaceEvent(nil, event) + require.Empty(t, m.pendingRequests) + require.Len(t, ec.events, 1, "a removed session must not resume emitting metadata-free requests") +} + +func TestSurfaceCarriesOOPIFParentFrameIntoNavigation(t *testing.T) { + srv := newTestServer(t) + defer srv.close() + m, ec, cleanup := startMonitor(t, srv, nil) + defer cleanup() + srv.sendToMonitor(t, map[string]any{ + "method": "Target.attachedToTarget", + "params": map[string]any{ + "sessionId": "iframe-session", + "targetInfo": map[string]any{"targetId": "frame", "type": "iframe", "parentFrameId": "parent-frame"}, + }, + }) + require.Eventually(t, func() bool { + m.sessionsMu.RLock() + defer m.sessionsMu.RUnlock() + return m.sessions["iframe-session"].parentFrameID == "parent-frame" + }, time.Second, 10*time.Millisecond) + + for _, parentID := range []string{"", "explicit-parent"} { + checkpoint := ec.checkpoint() + srv.sendToMonitor(t, map[string]any{ + "method": "Page.frameNavigated", "sessionId": "iframe-session", + "params": map[string]any{"frame": map[string]any{ + "id": "frame", "url": "https://other.example/", "parentId": parentID, + }}, + }) + event := ec.waitForNew(t, EventNavigation, checkpoint, time.Second) + var data oapi.BrowserPageNavigationEventData + require.NoError(t, json.Unmarshal(event.Data, &data)) + require.NotNil(t, data.ParentFrameId) + expected := parentID + if expected == "" { + expected = "parent-frame" + } + require.Equal(t, expected, *data.ParentFrameId) + require.Equal(t, mainSessionUnset, m.mainSessionID.Load()) + } +} diff --git a/server/lib/cdpmonitor/types.go b/server/lib/cdpmonitor/types.go index 8c422973f..20147f531 100644 --- a/server/lib/cdpmonitor/types.go +++ b/server/lib/cdpmonitor/types.go @@ -75,9 +75,10 @@ const targetTypePage = "page" // targetInfo holds metadata about an attached CDP target/session. type targetInfo struct { - targetID string - url string - targetType string + targetID string + url string + targetType string + parentFrameID string } // cdpError is the JSON-RPC error object returned by Chrome. From 93da8370112e8e7c3977026bdb4c0ad35571bf04 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:51:44 +0000 Subject: [PATCH 5/5] Bound Chromium test startup and drain stderr --- server/lib/cdpmonitor/chrome_e2e_test.go | 39 ++++++++--- server/lib/cdpmonitor/chrome_startup_test.go | 64 +++++++++++++++++++ .../cdpmonitor/lifecycle_chrome_e2e_test.go | 2 +- 3 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 server/lib/cdpmonitor/chrome_startup_test.go diff --git a/server/lib/cdpmonitor/chrome_e2e_test.go b/server/lib/cdpmonitor/chrome_e2e_test.go index c4bf243ba..572166759 100644 --- a/server/lib/cdpmonitor/chrome_e2e_test.go +++ b/server/lib/cdpmonitor/chrome_e2e_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "os" @@ -122,31 +123,51 @@ func launchChromium(t *testing.T, ctx context.Context, chrome string, extraArgs args = append(args, "about:blank") cmd := exec.CommandContext(ctx, chrome, args...) cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - stderr, err := cmd.StderrPipe() + stderr, stderrWriter, err := os.Pipe() require.NoError(t, err) + defer stderrWriter.Close() + t.Cleanup(func() { _ = stderr.Close() }) + cmd.Stderr = stderrWriter require.NoError(t, cmd.Start()) + _ = stderrWriter.Close() t.Cleanup(func() { // Stop renderer/worker children before TempDir cleanup, not just Chrome's parent. _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - _, _ = cmd.Process.Wait() + _ = cmd.Wait() }) - // Chromium prints "DevTools listening on ws://..." to stderr once ready. + // Cold Chrome startup on CI can exceed 20 seconds; capture assertions keep + // their own deadlines after the browser is ready. + deadline := time.Now().Add(time.Minute) + if parentDeadline, ok := ctx.Deadline(); ok && parentDeadline.Before(deadline) { + deadline = parentDeadline + } + url, err := readDevToolsURL(stderr, deadline) + require.NoError(t, err) + go func() { _, _ = io.Copy(io.Discard, stderr) }() + return url +} + +func readDevToolsURL(stderr *os.File, deadline time.Time) (string, error) { + if err := stderr.SetReadDeadline(deadline); err != nil { + return "", err + } + defer stderr.SetReadDeadline(time.Time{}) buf := make([]byte, 4096) - deadline := time.Now().Add(20 * time.Second) var out []byte - for time.Now().Before(deadline) { + for { n, readErr := stderr.Read(buf) out = append(out, buf[:n]...) if url := extractDevToolsURL(out); url != "" { - return url + return url, nil } if readErr != nil { - break + return "", fmt.Errorf("chromium did not report a DevTools URL: %w; stderr:\n%s", readErr, out) + } + if len(out) > 64*1024 { + out = out[len(out)-64*1024:] } } - t.Fatalf("chromium did not report a DevTools URL; stderr:\n%s", string(out)) - return "" } func extractDevToolsURL(b []byte) string { diff --git a/server/lib/cdpmonitor/chrome_startup_test.go b/server/lib/cdpmonitor/chrome_startup_test.go new file mode 100644 index 000000000..4cf437911 --- /dev/null +++ b/server/lib/cdpmonitor/chrome_startup_test.go @@ -0,0 +1,64 @@ +package cdpmonitor + +import ( + "io" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestReadDevToolsURLWaitsForCompleteLine(t *testing.T) { + reader, writer, err := os.Pipe() + require.NoError(t, err) + defer reader.Close() + defer writer.Close() + type result struct { + url string + err error + } + ready := make(chan result, 1) + go func() { + url, err := readDevToolsURL(reader, time.Now().Add(2*time.Second)) + ready <- result{url, err} + }() + _, err = io.WriteString(writer, "startup log\nDevTools listening on ws://127.0.0.1:") + require.NoError(t, err) + select { + case got := <-ready: + t.Fatalf("accepted incomplete URL: %+v", got) + case <-time.After(20 * time.Millisecond): + } + _, err = io.WriteString(writer, "1234/devtools/browser/test\n") + require.NoError(t, err) + select { + case got := <-ready: + require.NoError(t, got.err) + require.Equal(t, "ws://127.0.0.1:1234/devtools/browser/test", got.url) + case <-time.After(3 * time.Second): + t.Fatal("complete URL was not reported") + } +} + +func TestReadDevToolsURLBoundsSilentStartup(t *testing.T) { + reader, writer, err := os.Pipe() + require.NoError(t, err) + defer reader.Close() + defer writer.Close() + _, err = readDevToolsURL(reader, time.Now().Add(20*time.Millisecond)) + require.ErrorIs(t, err, os.ErrDeadlineExceeded) +} + +func TestReadDevToolsURLIncludesFailedStartupOutput(t *testing.T) { + reader, writer, err := os.Pipe() + require.NoError(t, err) + defer reader.Close() + defer writer.Close() + _, err = io.WriteString(writer, "startup failed\n") + require.NoError(t, err) + require.NoError(t, writer.Close()) + _, err = readDevToolsURL(reader, time.Now().Add(time.Second)) + require.ErrorIs(t, err, io.EOF) + require.ErrorContains(t, err, "startup failed") +} diff --git a/server/lib/cdpmonitor/lifecycle_chrome_e2e_test.go b/server/lib/cdpmonitor/lifecycle_chrome_e2e_test.go index f38419514..3c77048cf 100644 --- a/server/lib/cdpmonitor/lifecycle_chrome_e2e_test.go +++ b/server/lib/cdpmonitor/lifecycle_chrome_e2e_test.go @@ -21,7 +21,7 @@ func TestTelemetryConnectionOwnershipAndReconnect(t *testing.T) { if os.Getenv("KERNEL_CDPMONITOR_CHROME_E2E") == "" { t.Skip("set KERNEL_CDPMONITOR_CHROME_E2E=1 to run the connection ownership test") } - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost {