[recorder] Add a button to export steps as a Puppeteer script

Fixed: chromium:1209815
Change-Id: Id5d54c35fa057d494d72ce568cea9d0b6157e5c2
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2900441
Reviewed-by: Jan Scheffler <[email protected]>
Commit-Queue: Alex Rudenko <[email protected]>
diff --git a/front_end/core/i18n/locales/en-US.json b/front_end/core/i18n/locales/en-US.json
index 3566c60..ac2d193 100644
--- a/front_end/core/i18n/locales/en-US.json
+++ b/front_end/core/i18n/locales/en-US.json
@@ -8645,6 +8645,9 @@
   "panels/sources/OutlineQuickOpen.ts | openAJavascriptOrCssFileToSee": {
     "message": "Open a JavaScript or CSS file to see symbols"
   },
+  "panels/sources/RecorderPlugin.ts | export": {
+    "message": "Export"
+  },
   "panels/sources/RecorderPlugin.ts | play": {
     "message": "Replay"
   },
@@ -8804,6 +8807,9 @@
   "panels/sources/sources-meta.ts | evaluateSelectedTextInConsole": {
     "message": "Evaluate selected text in console"
   },
+  "panels/sources/sources-meta.ts | exportRecording": {
+    "message": "Export"
+  },
   "panels/sources/sources-meta.ts | filesystem": {
     "message": "Filesystem"
   },
diff --git a/front_end/core/i18n/locales/en-XL.json b/front_end/core/i18n/locales/en-XL.json
index 5bb7811..d747b1c 100644
--- a/front_end/core/i18n/locales/en-XL.json
+++ b/front_end/core/i18n/locales/en-XL.json
@@ -8645,6 +8645,9 @@
   "panels/sources/OutlineQuickOpen.ts | openAJavascriptOrCssFileToSee": {
     "message": "Ôṕêń â J́âv́âŚĉŕîṕt̂ ór̂ ĆŜŚ f̂íl̂é t̂ó ŝéê śŷḿb̂ól̂ś"
   },
+  "panels/sources/RecorderPlugin.ts | export": {
+    "message": "Êx́p̂ór̂t́"
+  },
   "panels/sources/RecorderPlugin.ts | play": {
     "message": "R̂ép̂ĺâý"
   },
@@ -8804,6 +8807,9 @@
   "panels/sources/sources-meta.ts | evaluateSelectedTextInConsole": {
     "message": "Êv́âĺûát̂é ŝél̂éĉt́êd́ t̂éx̂t́ îń ĉón̂śôĺê"
   },
+  "panels/sources/sources-meta.ts | exportRecording": {
+    "message": "Êx́p̂ór̂t́"
+  },
   "panels/sources/sources-meta.ts | filesystem": {
     "message": "F̂íl̂éŝýŝt́êḿ"
   },
diff --git a/front_end/models/recorder/RecorderModel.ts b/front_end/models/recorder/RecorderModel.ts
index 3886c15..ea61d50 100644
--- a/front_end/models/recorder/RecorderModel.ts
+++ b/front_end/models/recorder/RecorderModel.ts
@@ -6,7 +6,9 @@
 
 import * as Common from '../../core/common/common.js';
 import * as SDK from '../../core/sdk/sdk.js';
+import * as Bindings from '../../models/bindings/bindings.js';
 import * as UI from '../../ui/legacy/legacy.js';
+
 import type * as Workspace from '../workspace/workspace.js';
 import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js';
 
@@ -14,6 +16,7 @@
 import {RecordingSession} from './RecordingSession.js';
 import type {Step} from './Steps.js';
 import {ClickStep, NavigationStep, StepFrameContext, SubmitStep, ChangeStep, CloseStep, EmulateNetworkConditions} from './Steps.js';
+import {RecordingScriptWriter} from './RecordingScriptWriter.js';
 
 const enum RecorderState {
   Recording = 'Recording',
@@ -126,6 +129,22 @@
     this._currentRecordingSession.stop();
     this._currentRecordingSession = null;
   }
+
+  async exportRecording(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
+    const script = this.parseScript(uiSourceCode.content());
+    const writer = new RecordingScriptWriter('  ');
+    while (script.length) {
+      const step = script.shift();
+      step && writer.appendStep(step);
+    }
+    const filename = uiSourceCode.name();
+    const stream = new Bindings.FileUtils.FileOutputStream();
+    if (!await stream.open(filename + '.js')) {
+      return;
+    }
+    stream.write(writer.getScript());
+    stream.close();
+  }
 }
 
 SDK.SDKModel.SDKModel.register(RecorderModel, {capabilities: SDK.SDKModel.Capability.None, autostart: false});
diff --git a/front_end/panels/sources/RecorderPlugin.ts b/front_end/panels/sources/RecorderPlugin.ts
index b20a493..fe13e72 100644
--- a/front_end/panels/sources/RecorderPlugin.ts
+++ b/front_end/panels/sources/RecorderPlugin.ts
@@ -21,6 +21,10 @@
   *@description Text to replay a recording
   */
   play: 'Replay',
+  /**
+  *@description Text of a button to export as a Puppeteer script
+  */
+  export: 'Export',
 };
 const str_ = i18n.i18n.registerUIStrings('panels/sources/RecorderPlugin.ts', UIStrings);
 const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
@@ -45,7 +49,9 @@
     toggleRecording.setText(i18nString(UIStrings.record));
     const replayRecording = UI.Toolbar.Toolbar.createActionButtonForId('recorder.replay-recording');
     replayRecording.setText(i18nString(UIStrings.play));
+    const exportRecording = UI.Toolbar.Toolbar.createActionButtonForId('recorder.export-recording');
+    exportRecording.setText(i18nString(UIStrings.export));
 
-    return [toggleRecording, replayRecording];
+    return [toggleRecording, replayRecording, exportRecording];
   }
 }
diff --git a/front_end/panels/sources/SourcesPanel.ts b/front_end/panels/sources/SourcesPanel.ts
index 8a5b79b..b5daee9 100644
--- a/front_end/panels/sources/SourcesPanel.ts
+++ b/front_end/panels/sources/SourcesPanel.ts
@@ -659,6 +659,22 @@
     recorderModel.replayRecording(uiSourceCode);
   }
 
+  _exportRecording(): void {
+    const uiSourceCode = this._sourcesView.currentUISourceCode();
+    if (!uiSourceCode) {
+      return;
+    }
+    const target = UI.Context.Context.instance().flavor(SDK.SDKModel.Target);
+    if (!target) {
+      return;
+    }
+    const recorderModel = target.model(Recorder.RecorderModel.RecorderModel);
+    if (!recorderModel) {
+      return;
+    }
+    recorderModel.exportRecording(uiSourceCode);
+  }
+
   _editorSelected(event: Common.EventTarget.EventTargetEvent): void {
     const uiSourceCode = (event.data as Workspace.UISourceCode.UISourceCode);
     if (this.editorView.mainWidget() &&
@@ -1277,6 +1293,10 @@
         panel._replayRecording();
         return true;
       }
+      case 'recorder.export-recording': {
+        panel._exportRecording();
+        return true;
+      }
       case 'debugger.toggle-breakpoints-active': {
         panel._toggleBreakpointsActive();
         return true;
diff --git a/front_end/panels/sources/sources-meta.ts b/front_end/panels/sources/sources-meta.ts
index e02a56d..14efcd3 100644
--- a/front_end/panels/sources/sources-meta.ts
+++ b/front_end/panels/sources/sources-meta.ts
@@ -137,6 +137,10 @@
   */
   replayRecording: 'Replay',
   /**
+  *@description Title of a button to export a recorded series of actions as a Puppeteer script
+  */
+  exportRecording: 'Export',
+  /**
   *@description Text of an item that stops the running task
   */
   stop: 'Stop',
@@ -778,6 +782,22 @@
 });
 
 UI.ActionRegistration.registerActionExtension({
+  actionId: 'recorder.export-recording',
+  experiment: Root.Runtime.ExperimentName.RECORDER,
+  category: UI.ActionRegistration.ActionCategory.RECORDER,
+  async loadActionDelegate() {
+    const Sources = await loadSourcesModule();
+    return Sources.SourcesPanel.DebuggingActionDelegate.instance();
+  },
+  title: i18nLazyString(UIStrings.exportRecording),
+  iconClass: UI.ActionRegistration.IconClass.LARGEICON_DOWNLOAD,
+  contextTypes() {
+    return maybeRetrieveContextTypes(Sources => [Sources.SourcesView.SourcesView]);
+  },
+  bindings: [],
+});
+
+UI.ActionRegistration.registerActionExtension({
   category: UI.ActionRegistration.ActionCategory.DEBUGGER,
   actionId: 'debugger.toggle-breakpoints-active',
   iconClass: UI.ActionRegistration.IconClass.LARGE_ICON_DEACTIVATE_BREAKPOINTS,
diff --git a/front_end/ui/legacy/ActionRegistration.ts b/front_end/ui/legacy/ActionRegistration.ts
index f3c5866..7c1ae1f 100644
--- a/front_end/ui/legacy/ActionRegistration.ts
+++ b/front_end/ui/legacy/ActionRegistration.ts
@@ -221,6 +221,7 @@
   LARGEICON_VISIBILITY = 'largeicon-visibility',
   LARGEICON_PHONE = 'largeicon-phone',
   LARGEICON_PLAY = 'largeicon-play',
+  LARGEICON_DOWNLOAD = 'largeicon-download',
   LARGEICON_PAUSE = 'largeicon-pause',
   LARGEICON_RESUME = 'largeicon-resume',
   LARGEICON_TRASH_BIN = 'largeicon-trash-bin',