Ahmad Kholid 2 лет назад
Родитель
Сommit
b2e20a7a50
33 измененных файлов с 642 добавлено и 85 удалено
  1. 10 0
      jsconfig.json
  2. 1 1
      package.json
  3. 7 1
      src/background/BackgroundWorkflowUtils.js
  4. 8 0
      src/background/index.js
  5. 15 1
      src/components/block/BlockBase.vue
  6. 21 0
      src/components/block/BlockGroup.vue
  7. 4 2
      src/components/content/shared/SharedElementSelector.vue
  8. 4 1
      src/components/newtab/app/AppLogsItems.vue
  9. 1 8
      src/components/newtab/logs/LogsHistory.vue
  10. 198 0
      src/components/newtab/workflow/editor/EditorDebugging.vue
  11. 45 23
      src/components/newtab/workflow/editor/EditorLocalActions.vue
  12. 6 1
      src/content/commandPalette/App.vue
  13. 0 1
      src/content/services/webService.js
  14. 4 0
      src/lib/vRemixicon.js
  15. 2 0
      src/locales/en/common.json
  16. 6 0
      src/locales/en/newtab.json
  17. 0 1
      src/newtab/App.vue
  18. 6 0
      src/newtab/pages/workflows/[id].vue
  19. 12 1
      src/utils/FindElement.js
  20. 9 0
      src/utils/dataExporter.js
  21. 142 0
      src/utils/getTranslateLog.js
  22. 1 1
      src/utils/message.js
  23. 8 0
      src/utils/shared.js
  24. 16 1
      src/workflowEngine/WorkflowEngine.js
  25. 20 0
      src/workflowEngine/WorkflowState.js
  26. 59 12
      src/workflowEngine/WorkflowWorker.js
  27. 5 0
      src/workflowEngine/blocksHandler/handlerDeleteData.js
  28. 2 0
      src/workflowEngine/blocksHandler/handlerExecuteWorkflow.js
  29. 2 2
      src/workflowEngine/blocksHandler/handlerExportData.js
  30. 2 2
      src/workflowEngine/blocksHandler/handlerJavascriptCode.js
  31. 22 17
      src/workflowEngine/blocksHandler/handlerLogData.js
  32. 1 1
      src/workflowEngine/blocksHandler/handlerSaveAssets.js
  33. 3 8
      src/workflowEngine/index.js

+ 10 - 0
jsconfig.json

@@ -0,0 +1,10 @@
+{
+  "compilerOptions": {
+    "baseUrl": "./",
+    "paths": {
+      "@/*": ["src/*"],
+      "@business": ["business/dev/*"]
+    }
+  },
+  "include": ["src/**/*", "utils/**/*"]
+}

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.27.2",
+  "version": "1.28.0",
   "description": "An extension for automating your browser by connecting blocks",
   "repository": {
     "type": "git",

+ 7 - 1
src/background/BackgroundWorkflowUtils.js

@@ -42,7 +42,13 @@ class BackgroundWorkflowUtils {
   static async executeWorkflow(workflowData, options) {
     if (workflowData.isDisabled) return;
 
-    startWorkflowExec(workflowData, options, false);
+    /**
+     * Under v2, the background runtime environment is a real browser window. It has DOM, URL...
+      But these don't exist under v3. v3 uses service_worker (https://developer.mozilla.org/zh-CN/docs/Web/API/Service_Worker_API), so a dashboard page is created to run the workflow
+      So v2 and isPopup are actually the same
+     */
+    const isMV2 = browser.runtime.getManifest().manifest_version === 2;
+    startWorkflowExec(workflowData, options, isMV2);
   }
 }
 

+ 8 - 0
src/background/index.js

@@ -191,6 +191,14 @@ message.on('recording:stop', async () => {
     console.error(error);
   }
 });
+message.on('workflow:resume', ({ id, nextBlock }) => {
+  if (!id) return;
+  workflowState.resume(id, nextBlock);
+});
+message.on('workflow:breakpoint', (id) => {
+  if (!id) return;
+  workflowState.update(id, { status: 'breakpoint' });
+});
 
 automa('background', message);
 

+ 15 - 1
src/components/block/BlockBase.vue

@@ -1,5 +1,9 @@
 <template>
-  <div class="block-base relative w-48" @dblclick.stop="$emit('edit')">
+  <div
+    class="block-base relative w-48"
+    :data-block-id="blockId"
+    @dblclick.stop="$emit('edit')"
+  >
     <div
       class="block-menu-container absolute top-0 hidden w-full"
       style="transform: translateY(-100%)"
@@ -65,6 +69,15 @@
     </div>
     <slot name="prepend" />
     <ui-card :class="contentClass" class="block-base__content relative z-10">
+      <v-remixicon
+        v-if="workflow?.data?.value.testingMode"
+        :class="{ 'text-red-500 dark:text-red-400': data.$breakpoint }"
+        class="absolute left-0 top-0"
+        name="riRecordCircleFill"
+        title="Set as breakpoint"
+        size="20"
+        @click="$emit('update', { $breakpoint: !data.$breakpoint })"
+      />
       <slot></slot>
     </ui-card>
     <slot name="append" />
@@ -95,6 +108,7 @@ const props = defineProps({
 defineEmits(['delete', 'edit', 'update', 'settings']);
 
 const isCopied = ref(false);
+const workflow = inject('workflow', null);
 const workflowUtils = inject('workflow-utils', null);
 
 function insertToClipboard() {

+ 21 - 0
src/components/block/BlockGroup.vue

@@ -50,6 +50,7 @@
         <div
           class="bg-input group flex items-center space-x-2 rounded-lg p-2"
           style="cursor: grab"
+          :data-block-id="element.id"
           @dragstart="onDragStart(element, $event)"
           @dragend="onDragEnd(element.itemId)"
         >
@@ -75,6 +76,17 @@
             </p>
           </div>
           <div class="invisible group-hover:visible">
+            <v-remixicon
+              v-if="workflow?.data?.value.testingMode"
+              :class="{
+                'text-red-500 dark:text-red-400': element.data.$breakpoint,
+              }"
+              title="Set as breakpoint"
+              name="riRecordCircleLine"
+              size="18"
+              class="mr-2 inline-block cursor-pointer"
+              @click="toggleBreakpoint(element, index)"
+            />
             <v-remixicon
               name="riPencilLine"
               size="18"
@@ -233,4 +245,13 @@ function handleDrop(event) {
   ];
   emit('update', { blocks: copyBlocks });
 }
+function toggleBreakpoint(item, index) {
+  const copyBlocks = [...props.data.blocks];
+  copyBlocks[index].data = {
+    ...copyBlocks[index].data,
+    $breakpoint: !item.data.$breakpoint,
+  };
+
+  emit('update', { blocks: copyBlocks });
+}
 </script>

+ 4 - 2
src/components/content/shared/SharedElementSelector.vue

@@ -288,8 +288,10 @@ function onKeydown(event) {
   );
 }
 function onMousedown(event) {
-  event.preventDefault();
-  event.stopPropagation();
+  if (event.target.id === 'automa-selector-overlay') {
+    event.preventDefault();
+    event.stopPropagation();
+  }
   retrieveElementsRect(event, 'selected');
 }
 function onMessage({ data }) {

+ 4 - 1
src/components/newtab/app/AppLogsItems.vue

@@ -290,7 +290,10 @@ function clearLogs() {
     okVariant: 'danger',
     body: t('log.clearLogs.description'),
     onConfirm: () => {
-      dbLogs.delete();
+      dbLogs.items.clear();
+      dbLogs.ctxData.clear();
+      dbLogs.logsData.clear();
+      dbLogs.histories.clear();
     },
   });
 }

+ 1 - 8
src/components/newtab/logs/LogsHistory.vue

@@ -296,7 +296,7 @@ import Papa from 'papaparse';
 import objectPath from 'object-path';
 import { countDuration, fileSaver } from '@/utils/helper';
 import { getBlocks } from '@/utils/getSharedData';
-import { dataExportTypes } from '@/utils/shared';
+import { dataExportTypes, messageHasReferences } from '@/utils/shared';
 import dayjs from '@/lib/dayjs';
 
 const SharedCodemirror = defineAsyncComponent(() =>
@@ -363,13 +363,6 @@ const tabs = [
   { id: 'referenceData.prevBlockData', name: 'Previous block data' },
   { id: 'replacedValue', name: 'Replaced value' },
 ];
-const messageHasReferences = [
-  'no-tab',
-  'invalid-active-tab',
-  'no-match-tab',
-  'invalid-body',
-  'element-not-found',
-];
 
 const { t, te } = useI18n();
 

+ 198 - 0
src/components/newtab/workflow/editor/EditorDebugging.vue

@@ -0,0 +1,198 @@
+<template>
+  <ui-card
+    v-if="workflowState?.state"
+    class="shadow-xl flex items-start fixed bottom-8 z-50 left-1/2 -translate-x-1/2"
+  >
+    <div class="mr-4 w-52">
+      <div class="flex items-center gap-2">
+        <ui-button
+          :disabled="workflowState.state.nextBlockBreakpoint"
+          variant="accent"
+          class="flex-1"
+          @click="toggleExecution"
+        >
+          <v-remixicon
+            :name="
+              workflowState.status === 'breakpoint'
+                ? 'riPlayLine'
+                : 'riPauseLine'
+            "
+            class="mr-2 -ml-1"
+          />
+          <span>
+            {{
+              t(
+                `common.${
+                  workflowState.status === 'breakpoint' ? 'resume' : 'pause'
+                }`
+              )
+            }}
+          </span>
+        </ui-button>
+        <ui-button
+          v-tooltip="t('workflow.testing.nextBlock')"
+          :disabled="workflowState.status !== 'breakpoint'"
+          icon
+          @click="nextBlock"
+        >
+          <v-remixicon name="riArrowLeftSLine" rotate="180" />
+        </ui-button>
+        <ui-button
+          v-tooltip="t('common.stop')"
+          icon
+          class="text-red-500 dark:text-red-600"
+          @click="stopWorkflow"
+        >
+          <v-remixicon name="riStopLine" />
+        </ui-button>
+      </div>
+      <ui-list
+        v-if="workflowState.state"
+        class="mt-4 overflow-auto h-[105px] scroll"
+      >
+        <ui-list-item
+          v-for="block in workflowState.state.currentBlock"
+          :key="block.id"
+          small
+        >
+          <div class="text-overflow text-sm w-full">
+            <div class="flex items-center">
+              <p class="flex-1 text-overflow">
+                {{ getBlockName(block.name) }}
+              </p>
+              <v-remixicon
+                title="Go to block"
+                name="riEyeLine"
+                size="18"
+                class="text-gray-600 dark:text-gray-200 cursor-pointer"
+                @click="$emit('goToBlock', block.id)"
+              />
+            </div>
+            <p
+              class="leading-tight text-overflow text-gray-600 dark:text-gray-200"
+            >
+              {{ t('workflow.testing.startRun') }}:
+              {{ dayjs(block.startedAt).format('HH:mm:ss, SSS') }}
+            </p>
+          </div>
+        </ui-list-item>
+      </ui-list>
+    </div>
+    <shared-codemirror
+      :model-value="JSON.stringify(workflowData, null, 2)"
+      :line-numbers="false"
+      hide-lang
+      readonly
+      lang="json"
+      class="h-40 w-64 scroll breakpoint-data"
+    />
+  </ui-card>
+</template>
+<script setup>
+import { defineAsyncComponent, computed, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import dayjs from '@/lib/dayjs';
+import { tasks } from '@/utils/shared';
+import { debounce } from '@/utils/helper';
+import { sendMessage } from '@/utils/message';
+
+const SharedCodemirror = defineAsyncComponent(() =>
+  import('@/components/newtab/shared/SharedCodemirror.vue')
+);
+
+const props = defineProps({
+  states: {
+    type: Array,
+    default: () => [],
+  },
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+defineEmits(['goToBlock']);
+
+let currentRunningEls = [];
+
+const { t, te } = useI18n();
+
+const workflowState = computed(() => props.states[0]);
+const workflowData = computed(() => {
+  if (!workflowState.value?.state?.ctxData) return {};
+  const { ctxData, dataSnapshot } = workflowState.value.state.ctxData;
+  const latestData = Object.values(ctxData).at(-1);
+  if (!latestData) return {};
+
+  return {
+    ...latestData,
+    referenceData: {
+      ...latestData.referenceData,
+      loopData: dataSnapshot[latestData.referenceData.loopData] ?? {},
+      variables: dataSnapshot[latestData.referenceData.variables] ?? {},
+    },
+  };
+});
+
+function getBlockName(blockId) {
+  const key = `workflow.blocks.${blockId}.name`;
+
+  return te(key) ? t(key) : tasks[blockId].name;
+}
+function toggleExecution() {
+  if (!workflowState.value) return;
+
+  if (workflowState.value.status === 'running') {
+    sendMessage('workflow:breakpoint', workflowState.value.id, 'background');
+  } else {
+    sendMessage(
+      'workflow:resume',
+      { id: workflowState.value.id },
+      'background'
+    );
+  }
+}
+function stopWorkflow() {
+  if (!workflowState.value) return;
+
+  sendMessage('workflow:stop', workflowState.value.id, 'background');
+}
+function nextBlock() {
+  sendMessage(
+    'workflow:resume',
+    { id: workflowState.value.id, nextBlock: true },
+    'background'
+  );
+}
+
+watch(
+  workflowState,
+  debounce(() => {
+    currentRunningEls.forEach((element) => {
+      element.classList.remove('current-running');
+    });
+
+    if (!workflowState.value?.state?.currentBlock) return;
+
+    const selectors = workflowState.value.state.currentBlock
+      .map((block) => `.vue-flow [data-block-id="${block.id}"]`)
+      .join(',');
+    const elements = document.querySelectorAll(selectors);
+
+    currentRunningEls = elements;
+    elements.forEach((el) => {
+      el.classList.add('current-running');
+    });
+  }, 200),
+  { immediate: true }
+);
+</script>
+<style>
+.breakpoint-data .cm-editor {
+  font-size: 13px;
+  padding-bottom: 0;
+}
+
+.current-running {
+  @apply ring;
+}
+</style>

+ 45 - 23
src/components/newtab/workflow/editor/EditorLocalActions.vue

@@ -122,18 +122,35 @@
         </ui-list-item>
       </ui-list>
     </ui-popover>
-    <button
-      v-if="!workflow.isDisabled"
-      v-tooltip.group="
-        `${t('common.execute')} (${
-          shortcuts['editor:execute-workflow'].readable
-        })`
-      "
-      class="hoverable rounded-lg p-2"
-      @click="executeCurrWorkflow"
-    >
-      <v-remixicon name="riPlayLine" />
-    </button>
+    <template v-if="!workflow.isDisabled">
+      <button
+        v-if="canEdit"
+        v-tooltip.group="
+          t(`workflow.testing.${isDataChanged ? 'disabled' : 'title'}`)
+        "
+        :class="[
+          { 'cursor-default': isDataChanged },
+          workflow.testingMode
+            ? 'bg-primary bg-primary bg-opacity-20 text-primary'
+            : 'hoverable',
+        ]"
+        class="rounded-lg p-2"
+        @click="toggleTestingMode"
+      >
+        <v-remixicon name="riBug2Line" />
+      </button>
+      <button
+        v-tooltip.group="
+          `${t('common.execute')} (${
+            shortcuts['editor:execute-workflow'].readable
+          })`
+        "
+        class="hoverable rounded-lg p-2"
+        @click="executeCurrWorkflow"
+      >
+        <v-remixicon name="riPlayLine" />
+      </button>
+    </template>
     <button
       v-else
       v-tooltip="t('workflow.clickToEnable')"
@@ -401,17 +418,6 @@ const userDontHaveTeamsAccess = computed(() => {
   );
 });
 
-function copyWorkflowId() {
-  navigator.clipboard.writeText(props.workflow.id).catch((error) => {
-    console.error(error);
-
-    const textarea = document.createElement('textarea');
-    textarea.value = props.workflow.id;
-    textarea.select();
-    document.execCommand('copy');
-    textarea.blur();
-  });
-}
 function updateWorkflow(data = {}, changedIndicator = false) {
   let store = null;
 
@@ -434,6 +440,22 @@ function updateWorkflow(data = {}, changedIndicator = false) {
     return result;
   });
 }
+function toggleTestingMode() {
+  if (props.isDataChanged) return;
+
+  updateWorkflow({ testingMode: !props.workflow.testingMode });
+}
+function copyWorkflowId() {
+  navigator.clipboard.writeText(props.workflow.id).catch((error) => {
+    console.error(error);
+
+    const textarea = document.createElement('textarea');
+    textarea.value = props.workflow.id;
+    textarea.select();
+    document.execCommand('copy');
+    textarea.blur();
+  });
+}
 function updateWorkflowDescription(value) {
   const keys = ['description', 'category', 'content', 'tag', 'name'];
   const payload = {};

+ 6 - 1
src/content/commandPalette/App.vue

@@ -290,8 +290,13 @@ function executeWorkflow(workflow) {
       keys.add(param.name);
     });
 
+    const parameters = cloneDeep(triggerData.parameters).map((item) => ({
+      ...item,
+      value: item.defaultValue,
+    }));
+
     paramsState.workflow = workflow;
-    paramsState.items = cloneDeep(triggerData.parameters);
+    paramsState.items = parameters;
 
     paramsState.active = true;
   } else {

+ 0 - 1
src/content/services/webService.js

@@ -201,7 +201,6 @@ window.addEventListener('user-logout', () => {
 });
 
 window.addEventListener('app-mounted', async () => {
-  console.log('haha');
   try {
     const STORAGE_KEY = 'supabase.auth.token';
     const session = parseJSON(localStorage.getItem(STORAGE_KEY), null);

+ 4 - 0
src/lib/vRemixicon.js

@@ -22,6 +22,7 @@ import {
   riTimeLine,
   riFlagLine,
   riFileLine,
+  riBug2Line,
   riTeamLine,
   riLinksLine,
   riGroupLine,
@@ -122,6 +123,7 @@ import {
   riArrowGoBackLine,
   riInputCursorMove,
   riCloseCircleLine,
+  riRecordCircleFill,
   riRecordCircleLine,
   riErrorWarningLine,
   riExternalLinkLine,
@@ -160,6 +162,7 @@ export const icons = {
   riTimeLine,
   riFlagLine,
   riFileLine,
+  riBug2Line,
   riTeamLine,
   riLinksLine,
   riGroupLine,
@@ -260,6 +263,7 @@ export const icons = {
   riArrowGoBackLine,
   riInputCursorMove,
   riCloseCircleLine,
+  riRecordCircleFill,
   riRecordCircleLine,
   riErrorWarningLine,
   riExternalLinkLine,

+ 2 - 0
src/locales/en/common.json

@@ -27,6 +27,8 @@
     "data": "data",
     "stop": "Stop",
     "sheet": "Sheet",
+    "pause": "Pause",
+    "resume": "Resume",
     "action": "Action | Actions",
     "packages": "Packages",
     "storage": "Storage",

+ 6 - 0
src/locales/en/newtab.json

@@ -196,6 +196,12 @@
       "preferInTab": "Prefer input parameters in the tab"
     },
     "my": "My workflows",
+    "testing": {
+      "title": "Testing mode",
+      "nextBlock": "Next block",
+      "startRun": "Start run at",
+      "disabled": "Save changes first"
+    },
     "import": "Import workflow",
     "new": "New workflow",
     "delete": "Delete workflow",

+ 0 - 1
src/newtab/App.vue

@@ -264,7 +264,6 @@ browser.runtime.onMessage.addListener(({ type, data }) => {
 
 browser.storage.local.onChanged.addListener(({ workflowStates }) => {
   if (!workflowStates) return;
-
   const states = Object.values(workflowStates.newValue);
   workflowStore.states = states;
 });

+ 6 - 0
src/newtab/pages/workflows/[id].vue

@@ -156,6 +156,11 @@
           </ui-tab-panel>
         </template>
         <ui-tab-panel cache value="editor" class="w-full" @keydown="onKeydown">
+          <editor-debugging
+            v-if="workflow.testingMode && workflowStates.length > 0"
+            :states="workflowStates"
+            @goToBlock="goToBlock"
+          />
           <workflow-editor
             v-if="state.workflowConverted"
             :id="route.params.id"
@@ -337,6 +342,7 @@ import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.
 import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
 import SharedPermissionsModal from '@/components/newtab/shared/SharedPermissionsModal.vue';
 import EditorAddPackage from '@/components/newtab/workflow/editor/EditorAddPackage.vue';
+import EditorDebugging from '@/components/newtab/workflow/editor/EditorDebugging.vue';
 import EditorPkgActions from '@/components/newtab/workflow/editor/EditorPkgActions.vue';
 import EditorLocalCtxMenu from '@/components/newtab/workflow/editor/EditorLocalCtxMenu.vue';
 import EditorLocalActions from '@/components/newtab/workflow/editor/EditorLocalActions.vue';

+ 12 - 1
src/utils/FindElement.js

@@ -4,7 +4,18 @@ import {
   querySelectorDeep,
 } from '@/lib/query-selector-shadow-dom';
 
-const specialSelectors = [':contains', ':header', ':parent'];
+// Add a custom "Sizzle" pseudo-class selector
+// ":contains": element content will be selected as long as it contains text
+// ":equal" : element content must be exactly the same as text to be selected
+// Example: p.description:equal("cat")
+Sizzle.selectors.pseudos.equal = Sizzle.selectors.createPseudo(function (text) {
+  return function (elem) {
+    const elemText = elem.textContent || elem.innerText || '';
+    return elemText.trim() === text;
+  };
+});
+
+const specialSelectors = [':contains', ':header', ':parent', ':equal'];
 const specialSelectorsRegex = new RegExp(specialSelectors.join('|'));
 
 class FindElement {

+ 9 - 0
src/utils/dataExporter.js

@@ -54,6 +54,15 @@ export default function (
     const extractObj = (obj) => {
       if (typeof obj !== 'object') return [obj];
 
+      // 需要处理深层对象 不然会返回:[object Object]
+      const kes = Object.keys(obj);
+      kes.forEach((key) => {
+        const itemValue = obj[key];
+        if (typeof itemValue === 'object') {
+          obj[key] = JSON.stringify(itemValue);
+        }
+      });
+
       return Object.values(obj);
     };
 

+ 142 - 0
src/utils/getTranslateLog.js

@@ -0,0 +1,142 @@
+import { getBlocks } from '@/utils/getSharedData';
+import dayjs from '@/lib/dayjs';
+import vueI18n from '@/lib/vueI18n';
+import { countDuration } from '@/utils/helper';
+import { messageHasReferences } from '@/utils/shared';
+
+const blocks = getBlocks();
+
+/**
+ * 转换日志
+ * @param {*} log
+ * @returns
+ */
+function translateLog(log) {
+  const copyLog = { ...log };
+  const getTranslatation = (path, def) => {
+    const params = typeof path === 'string' ? { path } : path;
+
+    return vueI18n.global.te(params.path)
+      ? vueI18n.global.t(params.path, params.params)
+      : def;
+  };
+
+  if (['finish', 'stop'].includes(log.type)) {
+    copyLog.name = vueI18n.global.t(`log.types.${log.type}`);
+  } else {
+    copyLog.name = getTranslatation(
+      `workflow.blocks.${log.name}.name`,
+      blocks[log.name].name
+    );
+  }
+
+  if (copyLog.message && messageHasReferences.includes(copyLog.message)) {
+    copyLog.messageId = `${copyLog.message}`;
+  }
+
+  copyLog.message = getTranslatation(
+    { path: `log.messages.${log.message}`, params: log },
+    log.message
+  );
+
+  return copyLog;
+}
+
+function getDataSnapshot(propsCtxData, refData) {
+  if (!propsCtxData?.dataSnapshot) return;
+
+  const data = propsCtxData.dataSnapshot;
+  const getData = (key) => {
+    const currentData = refData[key];
+    if (typeof currentData !== 'string') return currentData;
+
+    return data[currentData] ?? {};
+  };
+
+  refData.loopData = getData('loopData');
+  refData.variables = getData('variables');
+}
+
+/**
+ * 获取日志
+ * @param {*} dataType 日志数据类型
+ * @param {*} translatedLog 转换后的日志
+ * @returns
+ */
+function getLogs(dataType, translatedLog, curStateCtxData) {
+  let data = dataType === 'plain-text' ? '' : [];
+  const getItemData = {
+    'plain-text': ([
+      timestamp,
+      duration,
+      status,
+      name,
+      description,
+      message,
+      ctxData,
+    ]) => {
+      data += `${timestamp}(${countDuration(
+        0,
+        duration || 0
+      ).trim()}) - ${status} - ${name} - ${description} - ${message} - ${JSON.stringify(
+        ctxData
+      )} \n`;
+    },
+    json: ([
+      timestamp,
+      duration,
+      status,
+      name,
+      description,
+      message,
+      ctxData,
+    ]) => {
+      data.push({
+        timestamp,
+        duration: countDuration(0, duration || 0).trim(),
+        status,
+        name,
+        description,
+        message,
+        data: ctxData,
+      });
+    },
+  };
+  translatedLog.forEach((item, index) => {
+    let logData = curStateCtxData;
+    if (logData.ctxData) logData = logData.ctxData;
+
+    const itemData = logData[item.id] || null;
+    if (itemData) getDataSnapshot(curStateCtxData, itemData.referenceData);
+
+    getItemData[dataType](
+      [
+        dayjs(item.timestamp || Date.now()).format('YYYY-MM-DD HH:mm:ss'),
+        item.duration,
+        item.type.toUpperCase(),
+        item.name,
+        item.description || 'NULL',
+        item.message || 'NULL',
+        itemData,
+      ],
+      index
+    );
+  });
+  return data;
+}
+
+/**
+ * 获取日志数据
+ * @param {*} curState 当前工作流状态
+ * @param {*} dataType 日志数据类型 plain-text 和 json
+ * @returns
+ */
+export default function (curState, dataType = 'plain-text') {
+  const { logs: curStateHistory, ctxData: curStateCtxData } = curState;
+  // 经过转换后的日志
+  const translatedLog = curStateHistory.map(translateLog);
+  // 获取日志
+  const logs = getLogs(dataType, translatedLog, curStateCtxData);
+  // 获取日志
+  return logs;
+}

+ 1 - 1
src/utils/message.js

@@ -11,7 +11,7 @@ export class MessageListener {
   }
 
   on(name, listener) {
-    if (objectHasKey(this.listeners, 'name')) {
+    if (objectHasKey(this.listeners, name)) {
       console.error(`You already added ${name}`);
       return this.on;
     }

+ 8 - 0
src/utils/shared.js

@@ -1736,3 +1736,11 @@ export const conditionBuilder = {
     },
   },
 };
+
+export const messageHasReferences = [
+  'no-tab',
+  'invalid-active-tab',
+  'no-match-tab',
+  'invalid-body',
+  'element-not-found',
+];

+ 16 - 1
src/workflowEngine/WorkflowEngine.js

@@ -16,6 +16,7 @@ class WorkflowEngine {
     this.workflow = workflow;
     this.isPopup = isPopup ?? true;
     this.blocksHandler = blocksHandler;
+    this.isTestingMode = workflow.testingMode;
     this.parentWorkflow = options?.parentWorkflow;
     this.saveLog = workflow.settings?.saveLog ?? true;
     this.isMV2 = browser.runtime.getManifest().manifest_version === 2;
@@ -30,6 +31,7 @@ class WorkflowEngine {
 
     this.isDestroyed = false;
     this.isUsingProxy = false;
+    this.isInBreakpoint = false;
 
     this.triggerBlockId = null;
 
@@ -103,6 +105,13 @@ class WorkflowEngine {
       if (this.id !== id || this.isDestroyed) return;
       this.stop();
     };
+    this.onResumeExecution = ({ id, nextBlock }) => {
+      if (this.id !== id || this.isDestroyed) return;
+
+      this.workers.forEach((worker) => {
+        worker.resume(nextBlock);
+      });
+    };
   }
 
   async init() {
@@ -269,6 +278,7 @@ class WorkflowEngine {
       this.startedTimestamp = Date.now();
 
       this.states.on('stop', this.onWorkflowStopped);
+      this.states.on('resume', this.onResumeExecution);
 
       const credentials = await dbStorage.credentials.toArray();
       credentials.forEach(({ name, value }) => {
@@ -284,6 +294,7 @@ class WorkflowEngine {
 
       await this.states.add(this.id, {
         id: this.id,
+        status: 'running',
         state: this.state,
         workflowId: this.workflow.id,
         parentState: this.parentWorkflow,
@@ -555,7 +566,11 @@ class WorkflowEngine {
       tabIds: [],
       currentBlock: [],
       name: this.workflow.name,
-      logs: this.history.slice(-5),
+      logs: this.history,
+      ctxData: {
+        ctxData: this.historyCtxData,
+        dataSnapshot: this.refDataSnapshots,
+      },
       startedTimestamp: this.startedTimestamp,
     };
 

+ 20 - 0
src/workflowEngine/WorkflowState.js

@@ -81,8 +81,28 @@ class WorkflowState {
     return id;
   }
 
+  async resume(id, nextBlock) {
+    const state = this.states.get(id);
+    if (!state) return;
+
+    this.states.set(id, {
+      ...state,
+      status: 'running',
+    });
+    await this._saveToStorage();
+
+    this.dispatchEvent('resume', { id, nextBlock });
+  }
+
   async update(id, data = {}) {
     const state = this.states.get(id);
+    if (!state) return;
+
+    if (data?.state?.status) {
+      state.status = data.state.status;
+      delete data.state.status;
+    }
+
     this.states.set(id, { ...state, ...data });
     this.dispatchEvent('update', { id, data });
     await this._saveToStorage();

+ 59 - 12
src/workflowEngine/WorkflowWorker.js

@@ -46,6 +46,7 @@ class WorkflowWorker {
     this.loopList = {};
     this.repeatedTasks = {};
     this.preloadScripts = [];
+    this.breakpointState = null;
 
     this.windowId = null;
     this.currentBlock = null;
@@ -135,13 +136,22 @@ class WorkflowWorker {
     return [...connections.values()];
   }
 
-  executeNextBlocks(connections, prevBlockData) {
+  executeNextBlocks(
+    connections,
+    prevBlockData,
+    nextBlockBreakpointCount = null
+  ) {
     connections.forEach((connection, index) => {
       const { id, targetHandle, sourceHandle } =
         typeof connection === 'string'
           ? { id: connection, targetHandle: '', sourceHandle: '' }
           : connection;
-      const execParam = { prevBlockData, targetHandle, sourceHandle };
+      const execParam = {
+        prevBlockData,
+        targetHandle,
+        sourceHandle,
+        nextBlockBreakpointCount,
+      };
 
       if (index === 0) {
         this.executeBlock(this.engine.blocks[id], {
@@ -167,6 +177,19 @@ class WorkflowWorker {
     });
   }
 
+  resume(nextBlock) {
+    if (!this.breakpointState) return;
+
+    const { block, execParam, isRetry } = this.breakpointState;
+    const payload = { ...execParam, resume: true };
+
+    payload.nextBlockBreakpointCount = nextBlock ? 1 : null;
+
+    this.executeBlock(block, payload, isRetry);
+
+    this.breakpointState = null;
+  }
+
   async executeBlock(block, execParam = {}, isRetry = false) {
     const currentState = await this.engine.states.get(this.engine.id);
 
@@ -181,11 +204,32 @@ class WorkflowWorker {
     const prevBlock = this.currentBlock;
     this.currentBlock = { ...block, startedAt: startExecuteTime };
 
+    const isInBreakpoint =
+      this.engine.isTestingMode &&
+      ((block.data?.$breakpoint && !execParam.resume) ||
+        execParam.nextBlockBreakpointCount === 0);
+
     if (!isRetry) {
-      await this.engine.updateState({
+      const payload = {
         activeTabUrl: this.activeTab.url,
         childWorkflowId: this.childWorkflowId,
-      });
+        nextBlockBreakpoint: Boolean(execParam.nextBlockBreakpointCount),
+      };
+      if (isInBreakpoint && currentState.status !== 'breakpoint')
+        payload.status = 'breakpoint';
+
+      await this.engine.updateState(payload);
+    }
+
+    if (execParam.nextBlockBreakpointCount) {
+      execParam.nextBlockBreakpointCount -= 1;
+    }
+
+    if (isInBreakpoint || currentState.status === 'breakpoint') {
+      this.engine.isInBreakpoint = true;
+      this.breakpointState = { block, execParam, isRetry };
+
+      return;
     }
 
     const blockHandler = this.engine.blocksHandler[toCamelCase(block.label)];
@@ -238,6 +282,14 @@ class WorkflowWorker {
       });
     };
 
+    const executeBlocks = (blocks, data) => {
+      return this.executeNextBlocks(
+        blocks,
+        data,
+        execParam.nextBlockBreakpointCount
+      );
+    };
+
     try {
       let result;
 
@@ -253,11 +305,6 @@ class WorkflowWorker {
           ...(execParam || {}),
         });
         result = await blockExecutionWrapper(bindedHandler, block.data);
-        // result = await handler.call(this, replacedBlock, {
-        //   refData,
-        //   prevBlock,
-        //   ...(execParam || {}),
-        // });
 
         if (this.engine.isDestroyed) return;
 
@@ -273,7 +320,7 @@ class WorkflowWorker {
 
       if (result.nextBlockId && !result.destroyWorker) {
         setTimeout(() => {
-          this.executeNextBlocks(result.nextBlockId, result.data);
+          executeBlocks(result.nextBlockId, result.data);
         }, blockDelay);
       } else {
         this.engine.destroyWorker(this.id);
@@ -319,7 +366,7 @@ class WorkflowWorker {
         if (blockOnError.toDo !== 'error' && nextBlocks) {
           addBlockLog('error', errorLogData);
 
-          this.executeNextBlocks(nextBlocks, prevBlockData);
+          executeBlocks(nextBlocks, prevBlockData);
 
           return;
         }
@@ -335,7 +382,7 @@ class WorkflowWorker {
 
       if (onError === 'keep-running' && nodeConnections) {
         setTimeout(() => {
-          this.executeNextBlocks(nodeConnections, error.data || '');
+          executeBlocks(nodeConnections, error.data || '');
         }, blockDelay);
       } else if (onError === 'restart-workflow' && !this.parentWorkflow) {
         const restartCount = this.engine.restartWorkersCount[this.id] || 0;

+ 5 - 0
src/workflowEngine/blocksHandler/handlerDeleteData.js

@@ -1,5 +1,7 @@
 function deleteData({ data, id }) {
   return new Promise((resolve) => {
+    let variableDeleted = false;
+
     data.deleteList.forEach((item) => {
       if (item.type === 'table') {
         if (item.columnId === '[all]') {
@@ -24,9 +26,12 @@ function deleteData({ data, id }) {
         }
       } else if (item.variableName) {
         delete this.engine.referenceData.variables[item.variableName];
+        variableDeleted = true;
       }
     });
 
+    if (variableDeleted) this.engine.addRefDataSnapshot('variables');
+
     resolve({
       data: '',
       nextBlockId: this.getBlockConnections(id),

+ 2 - 0
src/workflowEngine/blocksHandler/handlerExecuteWorkflow.js

@@ -72,6 +72,8 @@ async function executeWorkflow({ id: blockId, data }) {
   workflow = convertWorkflowData(workflow);
   const optionsParams = { variables: {} };
 
+  if (workflow.testingMode) workflow.testingMode = false;
+
   if (!isWhitespace(data.globalData))
     optionsParams.globalData = data.globalData;
 

+ 2 - 2
src/workflowEngine/blocksHandler/handlerExportData.js

@@ -35,12 +35,12 @@ async function exportData({ data, id }, { refData }) {
       delimiter: data.csvDelimiter || ',',
     },
     returnUrl: hasDownloadAccess,
-    returnBlob: !this.engine.isPopup,
+    returnBlob: !this.engine.isPopup && !this.engine.isMV2,
   });
 
   if (!this.engine.isPopup && !hasDownloadAccess) {
     throw new Error("Don't have download permission");
-  } else if (!this.engine.isPopup) {
+  } else if (!this.engine.isPopup && !this.engine.isMV2) {
     blobUrl = await blobToBase64(blobUrl);
   }
 

+ 2 - 2
src/workflowEngine/blocksHandler/handlerJavascriptCode.js

@@ -165,9 +165,9 @@ export async function javascriptCode({ outputs, data, ...block }, { refData }) {
   }
 
   const inSandbox =
+    (this.engine.isMV2 || this.engine.isPopup) &&
     BROWSER_TYPE !== 'firefox' &&
-    data.context === 'background' &&
-    this.engine.isPopup;
+    data.context === 'background';
   const result = await (inSandbox
     ? messageSandbox('javascriptBlock', {
         instanceId,

+ 22 - 17
src/workflowEngine/blocksHandler/handlerLogData.js

@@ -1,32 +1,37 @@
-import dbLogs from '@/db/logs';
+import getTranslateLog from '@/utils/getTranslateLog';
+import { workflowState } from '../index';
 
 export async function logData({ id, data }) {
   if (!data.workflowId) {
     throw new Error('No workflow is selected');
   }
 
-  const [workflowLog] = await dbLogs.items
-    .where('workflowId')
-    .equals(data.workflowId)
-    .reverse()
-    .sortBy('endedAt');
-  let workflowLogData = null;
+  // 工作流状态数组
+  const { states } = workflowState;
+  let logs = [];
+  if (states) {
+    // 转换为数组
+    const stateValues = Object.values(Object.fromEntries(states));
+    // 当前工作流状态
+    const curWorkflowState = stateValues.find(
+      (item) => item.workflowId === data.workflowId
+    )?.state;
 
-  if (workflowLog) {
-    workflowLogData = (
-      await dbLogs.logsData.where('logId').equals(workflowLog.id).first()
-    ).data;
+    if (curWorkflowState) {
+      // 当前工作流最新日志
+      logs = getTranslateLog(curWorkflowState, 'json');
 
-    if (data.assignVariable) {
-      this.setVariable(data.variableName, workflowLogData);
-    }
-    if (data.saveData) {
-      this.addDataToColumn(data.dataColumn, workflowLogData);
+      if (data.assignVariable) {
+        this.setVariable(data.variableName, logs);
+      }
+      if (data.saveData) {
+        this.addDataToColumn(data.dataColumn, logs);
+      }
     }
   }
 
   return {
-    data: workflowLogData,
+    data: logs,
     nextBlockId: this.getBlockConnections(id),
   };
 }

+ 1 - 1
src/workflowEngine/blocksHandler/handlerSaveAssets.js

@@ -29,7 +29,7 @@ export default async function ({ data, id, label }) {
   let index = 0;
   const downloadFile = (url) => {
     const options = { url, conflictAction: data.onConflict };
-    let filename = data.filename || getFilename(url);
+    let filename = decodeURIComponent(data.filename || getFilename(url));
 
     if (filename) {
       if (data.onConflict === 'overwrite' && index !== 0) {

+ 3 - 8
src/workflowEngine/index.js

@@ -2,7 +2,6 @@
 import { toRaw } from 'vue';
 import browser from 'webextension-polyfill';
 import dayjs from '@/lib/dayjs';
-import decryptFlow, { getWorkflowPass } from '@/utils/decryptFlow';
 import { parseJSON } from '@/utils/helper';
 import { fetchApi } from '@/utils/api';
 import { sendMessage } from '@/utils/message';
@@ -45,13 +44,9 @@ export function startWorkflowExec(workflowData, options, isPopup = true) {
     self.localStorage.setItem('runCounts', JSON.stringify(runCounts));
   }
 
-  if (workflowData.isProtected) {
-    const flow = parseJSON(workflowData.drawflow, null);
-
-    if (!flow) {
-      const pass = getWorkflowPass(workflowData.pass);
-
-      workflowData.drawflow = decryptFlow(workflowData, pass);
+  if (workflowData.testingMode) {
+    for (const value of workflowState.states.values()) {
+      if (value.workflowId === workflowData.id) return null;
     }
   }