Allow debugger to load a trace from Local Storage.

This gives shaders.skia.org a simple pathway to hand off a trace to the
debugger. When a trace is collected, we can store it in Local Storage
and then redirect the user to `/debug?local-storage`, which will detect
a usable DebugTrace JSON in Local Storage and then immediately debug it
instead of prompting the user for a file.

The query parameter exists to avoid accidentally locking users into a
trace forever. Traces can always be loaded by dragging them in, but
without the initial prompt, a user won't know to try it.

Testing is accomplished by giving the test code hooks which can replace
the window's query parameter and Local Storage object at
initialization time.

Bug: skia:12818
Change-Id: I8f4a61a9efd8f523a2e517d40f6e5b42d3753fae
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/494823
Auto-Submit: John Stiles <johnstiles@google.com>
Reviewed-by: Joe Gregorio <jcgregorio@google.com>
Commit-Queue: Joe Gregorio <jcgregorio@google.com>
diff --git a/shaders/modules/debugger-app-sk/debugger-app-sk.ts b/shaders/modules/debugger-app-sk/debugger-app-sk.ts
index 7282ca8..31586bb 100644
--- a/shaders/modules/debugger-app-sk/debugger-app-sk.ts
+++ b/shaders/modules/debugger-app-sk/debugger-app-sk.ts
@@ -35,6 +35,11 @@
   modeProps: { fold: ['brace', 'include'] },
 });
 
+enum ErrorReporting {
+  Yes = 1,
+  No = 0
+}
+
 export class DebuggerAppSk extends ElementSk {
   private trace: DebugTrace | null = null;
 
@@ -44,10 +49,22 @@
 
   private currentLineHandle: CodeMirror.LineHandle | null = null;
 
+  private localStorage: Storage = window.localStorage; // can be overridden in tests
+
+  private queryParameter: string = window.location.search; // can be overridden in tests
+
   constructor() {
     super(DebuggerAppSk.template);
   }
 
+  setLocalStorageForTest(mockStorage: Storage): void {
+    this.localStorage = mockStorage;
+  }
+
+  setQueryParameterForTest(overrideQueryParam: string): void {
+    this.queryParameter = overrideQueryParam;
+  }
+
   private static themeFromCurrentMode(): string {
     return isDarkMode() ? 'ambiance' : 'base16-light';
   }
@@ -80,6 +97,18 @@
     document.addEventListener('theme-chooser-toggle', () => {
       this.codeMirror!.setOption('theme', DebuggerAppSk.themeFromCurrentMode());
     });
+
+    // If ?local-storage(=anything), try loading a debug trace from local storage.
+    const params = new URLSearchParams(this.queryParameter);
+    if (params.has('local-storage')) {
+      this.loadJSONData(this.localStorage.getItem('sksl-debug-trace')!, ErrorReporting.No);
+
+      // Remove ?local-storage from the query parameters on the window, so a reload or copy-paste
+      // will present a clean slate.
+      const url = new URL(window.location.toString());
+      url.searchParams.delete('local-storage');
+      window.history.pushState({}, '', url.toString());
+    }
   }
 
   getEditor(): CodeMirror.Editor | null {
@@ -135,7 +164,7 @@
     return [html`<tr><td>&nbsp;</td></tr>`];
   }
 
-  loadJSONData(jsonData: string): void {
+  loadJSONData(jsonData: string, reportErrors?: ErrorReporting): void {
     try {
       this.trace = Convert.toDebugTrace(jsonData);
       this.codeMirror!.setValue(this.trace.source.join('\n'));
@@ -144,7 +173,9 @@
       this.resetBreakpointGutter();
       this._render();
     } catch (ex) {
-      this.codeMirror!.setValue((ex instanceof Error) ? ex.message : String(ex));
+      if (reportErrors ?? ErrorReporting.Yes) {
+        this.codeMirror!.setValue((ex instanceof Error) ? ex.message : String(ex));
+      }
     }
   }
 
diff --git a/shaders/modules/debugger-app-sk/debugger-app-sk_test.ts b/shaders/modules/debugger-app-sk/debugger-app-sk_test.ts
index 6f939c0..92c0f40 100644
--- a/shaders/modules/debugger-app-sk/debugger-app-sk_test.ts
+++ b/shaders/modules/debugger-app-sk/debugger-app-sk_test.ts
@@ -6,6 +6,30 @@
 import { exampleTraceString } from './demo_data';
 import CodeMirror from 'codemirror';
 
+function makeFakeLocalStorage(store: Record<string, string>): Storage {
+  const fakeLocalStorage: Storage = {
+    length: 0,
+    getItem: (key: string): string | null => {
+      return key in store ? store[key] as string : null;
+    },
+    setItem: (key: string, value: string) => {
+      store[key] = `${value}`;
+      length = Object.keys(store).length;
+    },
+    removeItem: (key: string) => {
+      delete store[key];
+      length = Object.keys(store).length;
+    },
+    clear: () => {
+      store = {};
+    },
+    key: function (index: number): string | null {
+      return Object.keys(store)[index];
+    }
+  };
+  return fakeLocalStorage;
+}
+
 function getLinesWithBgClass(app: DebuggerAppSk, expectedType: string): number[] {
   const editor: CodeMirror.Editor = app.getEditor()!;
   assert.isNotNull(editor);
@@ -58,9 +82,9 @@
   return getLinesWithBreakpointMarker(app, 'cm-breakpoint');
 }
 
-describe('debugger-app-sk', () => {
-  const newInstance = setUpElementUnderTest<DebuggerAppSk>('debugger-app-sk');
+const newInstance = setUpElementUnderTest<DebuggerAppSk>('debugger-app-sk');
 
+describe('debugger app', () => {
   let debuggerAppSk: DebuggerAppSk;
 
   beforeEach(() => {
@@ -161,3 +185,51 @@
     assert.equal(getCurrentLine(debuggerAppSk), null);
   });
 });
+
+describe('local storage', () => {
+  let debuggerAppSk: DebuggerAppSk;
+
+  it('loads a trace when populated and ?local-storage query param exists', () => {
+    debuggerAppSk = newInstance((self: DebuggerAppSk) => {
+      self.setLocalStorageForTest(makeFakeLocalStorage({'sksl-debug-trace': exampleTraceString}));
+      self.setQueryParameterForTest('?local-storage');
+    });
+
+    const codeAreaText = $$<HTMLDivElement>('#codeEditor')?.innerText;
+    assert.include(codeAreaText, 'half4 convert(float2 c) {');
+    assert.include(codeAreaText, 'half4 c = convert(p * 0.001);');
+    assert.notInclude(codeAreaText, 'Drag in a DebugTrace JSON file to start the debugger.');
+  });
+
+  it('does nothing when ?local-storage query param is not present', () => {
+    debuggerAppSk = newInstance((self: DebuggerAppSk) => {
+      self.setLocalStorageForTest(makeFakeLocalStorage({'sksl-debug-trace': exampleTraceString}));
+    });
+
+    const codeAreaText = $$<HTMLDivElement>('#codeEditor')?.innerText;
+    assert.notInclude(codeAreaText, 'half4 convert(float2 c) {');
+    assert.include(codeAreaText, 'Drag in a DebugTrace JSON file to start the debugger.');
+  });
+
+  it('does nothing when local storage is invalid, even if ?local-storage is set', () => {
+    debuggerAppSk = newInstance((self: DebuggerAppSk) => {
+      self.setLocalStorageForTest(makeFakeLocalStorage({'sksl-debug-trace': '{}'}));
+      self.setQueryParameterForTest('?local-storage');
+    });
+
+    const codeAreaText = $$<HTMLDivElement>('#codeEditor')?.innerText;
+    assert.notInclude(codeAreaText, 'half4 convert(float2 c) {');
+    assert.include(codeAreaText, 'Drag in a DebugTrace JSON file to start the debugger.');
+  });
+
+  it('does nothing when local storage is empty, even if ?local-storage is set', () => {
+    debuggerAppSk = newInstance((self: DebuggerAppSk) => {
+      self.setLocalStorageForTest(makeFakeLocalStorage({}));
+      self.setQueryParameterForTest('?local-storage');
+    });
+
+    const codeAreaText = $$<HTMLDivElement>('#codeEditor')?.innerText;
+    assert.notInclude(codeAreaText, 'half4 convert(float2 c) {');
+    assert.include(codeAreaText, 'Drag in a DebugTrace JSON file to start the debugger.');
+  });
+});