tbonelee commented on code in PR #5072:
URL: https://github.com/apache/zeppelin/pull/5072#discussion_r2361916535


##########
zeppelin-web-angular/e2e/reporter.coverage.ts:
##########


Review Comment:
   Could we move the interface-implemented methods to the top? It would make 
class's main purpose clearer.



##########
zeppelin-web-angular/e2e/reporter.coverage.ts:
##########
@@ -0,0 +1,304 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// @see https://playwright.dev/docs/test-reporters#custom-reporters
+import { FullResult, Reporter, TestCase, TestResult } from 
'@playwright/test/reporter';
+import { promises as fs } from 'fs';
+import { flatMap, sortBy } from 'lodash';
+import { scanDirectory, Results } from 'scandirectory';
+import cfg from './reporter.coverage.config';
+
+const TEST_STATUS = {
+  PASSED: 'passed',
+  SKIPPED: 'skipped',
+  FAILED: 'failed'
+} as const;
+
+type ResultsType = Array<[string, number, number, number, number, number]>;

Review Comment:
   Could you change this tuple array type into an array of interface with named 
properties for better readability?
   e.g.
   ```typescript
   type Result = {
     path: string;
     total: number;
     success: number;
     failed: number;
     skipped: number;
     rate: number;
   }
   type Results = Array<Result>
   ```



##########
zeppelin-web-angular/e2e/utils.ts:
##########
@@ -0,0 +1,139 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { test, TestInfo } from '@playwright/test';
+
+export const PAGES = {
+  // Main App
+  APP: 'src/app/app.component',
+
+  // Core
+  CORE: {
+    DESTROY_HOOK: 'src/app/core/destroy-hook/destroy-hook.component'
+  },
+
+  // Pages
+  PAGES: {
+    LOGIN: 'src/app/pages/login/login.component'
+  },
+
+  // Pages - Workspace
+  WORKSPACE: {
+    MAIN: 'src/app/pages/workspace/workspace.component',
+    CONFIGURATION: 
'src/app/pages/workspace/configuration/configuration.component',
+    CREDENTIAL: 'src/app/pages/workspace/credential/credential.component',
+    HOME: 'src/app/pages/workspace/home/home.component',
+    INTERPRETER: 'src/app/pages/workspace/interpreter/interpreter.component',
+    INTERPRETER_CREATE_REPO:
+      
'src/app/pages/workspace/interpreter/create-repository-modal/create-repository-modal.component',
+    INTERPRETER_ITEM: 
'src/app/pages/workspace/interpreter/item/item.component',
+    JOB_MANAGER: 'src/app/pages/workspace/job-manager/job-manager.component',
+    JOB_STATUS: 
'src/app/pages/workspace/job-manager/job-status/job-status.component',
+    JOB: 'src/app/pages/workspace/job-manager/job/job.component',
+    NOTEBOOK_REPOS: 
'src/app/pages/workspace/notebook-repos/notebook-repos.component',
+    NOTEBOOK_REPOS_ITEM: 
'src/app/pages/workspace/notebook-repos/item/item.component',
+    NOTEBOOK_SEARCH: 
'src/app/pages/workspace/notebook-search/notebook-search.component',
+    NOTEBOOK_SEARCH_RESULT: 
'src/app/pages/workspace/notebook-search/result-item/result-item.component',
+    NOTEBOOK: 'src/app/pages/workspace/notebook/notebook.component',
+    NOTEBOOK_ACTION_BAR: 
'src/app/pages/workspace/notebook/action-bar/action-bar.component',
+    NOTEBOOK_ADD_PARAGRAPH: 
'src/app/pages/workspace/notebook/add-paragraph/add-paragraph.component',
+    NOTEBOOK_INTERPRETER_BINDING: 
'src/app/pages/workspace/notebook/interpreter-binding/interpreter-binding.component',
+    NOTEBOOK_NOTE_FORM: 
'src/app/pages/workspace/notebook/note-form-block/note-form-block.component',
+    NOTEBOOK_PERMISSIONS: 
'src/app/pages/workspace/notebook/permissions/permissions.component',
+    NOTEBOOK_REVISIONS: 
'src/app/pages/workspace/notebook/revisions-comparator/revisions-comparator.component',
+    NOTEBOOK_ELASTIC_INPUT: 
'src/app/pages/workspace/notebook/share/elastic-input/elastic-input.component',
+    NOTEBOOK_SIDEBAR: 
'src/app/pages/workspace/notebook/sidebar/sidebar.component',
+    NOTEBOOK_PARAGRAPH: 
'src/app/pages/workspace/notebook/paragraph/paragraph.component',
+    NOTEBOOK_PARAGRAPH_CODE_EDITOR: 
'src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component',
+    NOTEBOOK_PARAGRAPH_CONTROL: 
'src/app/pages/workspace/notebook/paragraph/control/control.component',
+    NOTEBOOK_PARAGRAPH_FOOTER: 
'src/app/pages/workspace/notebook/paragraph/footer/footer.component',
+    NOTEBOOK_PARAGRAPH_PROGRESS: 
'src/app/pages/workspace/notebook/paragraph/progress/progress.component',
+    PUBLISHED_PARAGRAPH: 
'src/app/pages/workspace/published/paragraph/paragraph.component',
+    SHARE_DYNAMIC_FORMS: 
'src/app/pages/workspace/share/dynamic-forms/dynamic-forms.component',
+    SHARE_RESULT: 'src/app/pages/workspace/share/result/result.component'
+  },
+
+  // Share
+  SHARE: {
+    ABOUT_ZEPPELIN: 'src/app/share/about-zeppelin/about-zeppelin.component',
+    CODE_EDITOR: 'src/app/share/code-editor/code-editor.component',
+    FOLDER_RENAME: 'src/app/share/folder-rename/folder-rename.component',
+    HEADER: 'src/app/share/header/header.component',
+    NODE_LIST: 'src/app/share/node-list/node-list.component',
+    NOTE_CREATE: 'src/app/share/note-create/note-create.component',
+    NOTE_IMPORT: 'src/app/share/note-import/note-import.component',
+    NOTE_RENAME: 'src/app/share/note-rename/note-rename.component',
+    NOTE_TOC: 'src/app/share/note-toc/note-toc.component',
+    PAGE_HEADER: 'src/app/share/page-header/page-header.component',
+    RESIZE_HANDLE: 'src/app/share/resize-handle/resize-handle.component',
+    SHORTCUT: 'src/app/share/shortcut/shortcut.component',
+    SPIN: 'src/app/share/spin/spin.component'
+  },
+
+  // Visualizations
+  VISUALIZATIONS: {
+    AREA_CHART: 
'src/app/visualizations/area-chart/area-chart-visualization.component',
+    BAR_CHART: 
'src/app/visualizations/bar-chart/bar-chart-visualization.component',
+    LINE_CHART: 
'src/app/visualizations/line-chart/line-chart-visualization.component',
+    PIE_CHART: 
'src/app/visualizations/pie-chart/pie-chart-visualization.component',
+    SCATTER_CHART: 
'src/app/visualizations/scatter-chart/scatter-chart-visualization.component',
+    TABLE: 'src/app/visualizations/table/table-visualization.component',
+    COMMON: {
+      PIVOT_SETTING: 
'src/app/visualizations/common/pivot-setting/pivot-setting.component',
+      SCATTER_SETTING: 
'src/app/visualizations/common/scatter-setting/scatter-setting.component',
+      X_AXIS_SETTING: 
'src/app/visualizations/common/x-axis-setting/x-axis-setting.component'
+    }
+  },
+
+  // Projects
+  PROJECTS: {
+    JSON_VIS: 'projects/helium-vis-example/src/json-vis.component'
+  }
+} as const;
+
+export function testPage(pageName: string, testInfo: TestInfo) {

Review Comment:
   How about using a more descriptive name like `addPageAnnotation` or anything 
else?



##########
zeppelin-web-angular/e2e/reporter.coverage.ts:
##########
@@ -0,0 +1,304 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// @see https://playwright.dev/docs/test-reporters#custom-reporters
+import { FullResult, Reporter, TestCase, TestResult } from 
'@playwright/test/reporter';
+import { promises as fs } from 'fs';
+import { flatMap, sortBy } from 'lodash';
+import { scanDirectory, Results } from 'scandirectory';
+import cfg from './reporter.coverage.config';
+
+const TEST_STATUS = {
+  PASSED: 'passed',
+  SKIPPED: 'skipped',
+  FAILED: 'failed'
+} as const;
+
+type ResultsType = Array<[string, number, number, number, number, number]>;
+type TestStatusType = typeof TEST_STATUS[keyof typeof TEST_STATUS];
+interface TestedPathType {
+  success: number;
+  skipped: number;
+  failed: number;
+}
+
+const OUTPUT_FILE_NAME = 'coverage.log';
+const TABLE_COLUMNS = {
+  TOTAL: 1,
+  SUCCESS: 2,
+  FAILED: 3,
+  SKIPPED: 4
+} as const;
+
+class CoverageReporter implements Reporter {
+  testedPaths = new Map<string, TestedPathType>();
+  testedIds = new Map<string, TestStatusType>();
+  targetPaths: string[] = [];
+
+  async onBegin() {
+    console.log('Coverage reporter starting...');
+    console.log('Root path:', cfg.rootPath);
+
+    const results = await scanDirectory({
+      directory: cfg.rootPath
+    });
+
+    this.targetPaths = this.processScannedFiles(results);
+    console.log('Target paths:', this.targetPaths.length);
+  }
+
+  processScannedFiles(results: Results): string[] {
+    return Object.keys(results)
+      .filter(key => !results[key].directory)
+      .map(key => this.normalizeFilePath(key, results))
+      .filter(key => key !== '.')
+      .filter(key => this.shouldIncludeFile(key));
+  }
+
+  normalizeFilePath(key: string, results: Results): string {
+    if (/index\.tsx?$/.test(key)) {
+      return results[key].parent?.relativePath || '.';
+    }
+    return key.replace(/\.tsx?$/, '');
+  }
+
+  shouldIncludeFile(key: string): boolean {
+    if (cfg.testMatch?.length) {
+      const matchesTest = cfg.testMatch.some(rule => (rule instanceof RegExp ? 
rule.test(key) : rule === key));
+      if (!matchesTest) {
+        return false;
+      }
+    }
+
+    if (cfg.excludes?.length) {
+      const isExcluded = cfg.excludes.some(rule => (rule instanceof RegExp ? 
rule.test(key) : rule === key));
+      if (isExcluded) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  onTestEnd(test: TestCase, result: TestResult) {
+    const status =
+      result.status === TEST_STATUS.PASSED || result.status === 
TEST_STATUS.SKIPPED
+        ? (result.status as TestStatusType)
+        : TEST_STATUS.FAILED;
+    const pages = this.extractPageAnnotations(test);
+    const prevTestStatus = this.testedIds.get(test.id);
+
+    pages.forEach(page => {
+      this.updateTestedPath(page, status, prevTestStatus);
+    });
+
+    this.testedIds.set(test.id, status);
+  }
+
+  extractPageAnnotations(test: TestCase): string[] {
+    const annotations = test.annotations
+      .filter(({ type }) => type === 'page')
+      .map(({ description }) => description)
+      .filter((desc): desc is string => desc !== undefined);
+
+    return Array.from(new Set(annotations));
+  }
+
+  updateTestedPath(page: string, status: TestStatusType, prevStatus?: 
TestStatusType) {
+    if (this.testedPaths.has(page)) {
+      const currentTest = this.testedPaths.get(page)!;
+      const newTest = { ...currentTest };
+      this.decrementPreviousStatus(newTest, prevStatus);
+      this.incrementCurrentStatus(newTest, status);
+      this.testedPaths.set(page, newTest);
+      return;
+    }
+    this.testedPaths.set(page, {
+      success: status === TEST_STATUS.PASSED ? 1 : 0,
+      failed: status === TEST_STATUS.FAILED ? 1 : 0,
+      skipped: status === TEST_STATUS.SKIPPED ? 1 : 0
+    });
+  }
+
+  decrementPreviousStatus(draftState: TestedPathType, prevStatus?: 
TestStatusType) {
+    if (!prevStatus) {
+      return;
+    }
+
+    if (prevStatus === TEST_STATUS.PASSED && draftState.success > 0) {
+      draftState.success -= 1;
+    } else if (prevStatus === TEST_STATUS.SKIPPED && draftState.skipped > 0) {
+      draftState.skipped -= 1;
+    } else if (prevStatus === TEST_STATUS.FAILED && draftState.failed > 0) {
+      draftState.failed -= 1;
+    }
+  }
+
+  incrementCurrentStatus(draftState: TestedPathType, status: TestStatusType) {
+    if (status === TEST_STATUS.PASSED) {
+      draftState.success += 1;
+    } else if (status === TEST_STATUS.SKIPPED) {
+      draftState.skipped += 1;
+    } else if (status === TEST_STATUS.FAILED) {
+      draftState.failed += 1;
+    }
+  }
+
+  getResults(): ResultsType {
+    const testedPaths = Array.from(this.testedPaths.keys());
+    const results = flatMap(this.targetPaths, path => 
this.processTargetPath(path, testedPaths));
+
+    return sortBy(results, [5, 1, 0]).reverse();
+  }
+
+  processTargetPath(targetPath: string, testedPaths: string[]): ResultsType {
+    const matchingPaths = this.findMatchingPaths(targetPath, testedPaths);
+
+    if (matchingPaths.length > 0) {
+      return matchingPaths.map(path => this.createResultEntry(path));
+    }
+
+    return [[targetPath, 0, 0, 0, 0, 0]];
+  }
+
+  findMatchingPaths(targetPath: string, testedPaths: string[]): string[] {
+    const regExp = new RegExp(`^${targetPath.replace(/\[.*?\]/g, '[^/]+?')}$`);
+
+    return testedPaths.filter(key => {
+      if (!this.targetPaths.includes(key)) {
+        return regExp.test(key);
+      }
+      return targetPath === key;
+    });
+  }
+
+  createResultEntry(path: string): [string, number, number, number, number, 
number] {
+    const testData = this.testedPaths.get(path);
+    if (!testData) {
+      return [path, 0, 0, 0, 0, 0];
+    }
+
+    const { success, skipped, failed } = testData;
+    const total = success + failed + skipped;
+    const rate = this.toRate(success, total - skipped);
+
+    return [path, total, success, failed, skipped, rate];
+  }

Review Comment:
   Same as above.



##########
zeppelin-web-angular/e2e/reporter.coverage.ts:
##########
@@ -0,0 +1,304 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// @see https://playwright.dev/docs/test-reporters#custom-reporters
+import { FullResult, Reporter, TestCase, TestResult } from 
'@playwright/test/reporter';
+import { promises as fs } from 'fs';
+import { flatMap, sortBy } from 'lodash';
+import { scanDirectory, Results } from 'scandirectory';
+import cfg from './reporter.coverage.config';
+
+const TEST_STATUS = {
+  PASSED: 'passed',
+  SKIPPED: 'skipped',
+  FAILED: 'failed'
+} as const;
+
+type ResultsType = Array<[string, number, number, number, number, number]>;
+type TestStatusType = typeof TEST_STATUS[keyof typeof TEST_STATUS];
+interface TestedPathType {
+  success: number;
+  skipped: number;
+  failed: number;
+}
+
+const OUTPUT_FILE_NAME = 'coverage.log';
+const TABLE_COLUMNS = {
+  TOTAL: 1,
+  SUCCESS: 2,
+  FAILED: 3,
+  SKIPPED: 4
+} as const;
+
+class CoverageReporter implements Reporter {
+  testedPaths = new Map<string, TestedPathType>();
+  testedIds = new Map<string, TestStatusType>();
+  targetPaths: string[] = [];
+
+  async onBegin() {
+    console.log('Coverage reporter starting...');
+    console.log('Root path:', cfg.rootPath);
+
+    const results = await scanDirectory({
+      directory: cfg.rootPath
+    });
+
+    this.targetPaths = this.processScannedFiles(results);
+    console.log('Target paths:', this.targetPaths.length);
+  }
+
+  processScannedFiles(results: Results): string[] {
+    return Object.keys(results)
+      .filter(key => !results[key].directory)
+      .map(key => this.normalizeFilePath(key, results))
+      .filter(key => key !== '.')
+      .filter(key => this.shouldIncludeFile(key));
+  }
+
+  normalizeFilePath(key: string, results: Results): string {
+    if (/index\.tsx?$/.test(key)) {
+      return results[key].parent?.relativePath || '.';
+    }
+    return key.replace(/\.tsx?$/, '');
+  }
+
+  shouldIncludeFile(key: string): boolean {
+    if (cfg.testMatch?.length) {
+      const matchesTest = cfg.testMatch.some(rule => (rule instanceof RegExp ? 
rule.test(key) : rule === key));
+      if (!matchesTest) {
+        return false;
+      }
+    }
+
+    if (cfg.excludes?.length) {
+      const isExcluded = cfg.excludes.some(rule => (rule instanceof RegExp ? 
rule.test(key) : rule === key));
+      if (isExcluded) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  onTestEnd(test: TestCase, result: TestResult) {
+    const status =
+      result.status === TEST_STATUS.PASSED || result.status === 
TEST_STATUS.SKIPPED
+        ? (result.status as TestStatusType)
+        : TEST_STATUS.FAILED;
+    const pages = this.extractPageAnnotations(test);
+    const prevTestStatus = this.testedIds.get(test.id);
+
+    pages.forEach(page => {
+      this.updateTestedPath(page, status, prevTestStatus);
+    });
+
+    this.testedIds.set(test.id, status);
+  }
+
+  extractPageAnnotations(test: TestCase): string[] {
+    const annotations = test.annotations
+      .filter(({ type }) => type === 'page')
+      .map(({ description }) => description)
+      .filter((desc): desc is string => desc !== undefined);
+
+    return Array.from(new Set(annotations));
+  }
+
+  updateTestedPath(page: string, status: TestStatusType, prevStatus?: 
TestStatusType) {
+    if (this.testedPaths.has(page)) {
+      const currentTest = this.testedPaths.get(page)!;
+      const newTest = { ...currentTest };
+      this.decrementPreviousStatus(newTest, prevStatus);
+      this.incrementCurrentStatus(newTest, status);
+      this.testedPaths.set(page, newTest);
+      return;
+    }
+    this.testedPaths.set(page, {
+      success: status === TEST_STATUS.PASSED ? 1 : 0,
+      failed: status === TEST_STATUS.FAILED ? 1 : 0,
+      skipped: status === TEST_STATUS.SKIPPED ? 1 : 0
+    });
+  }
+
+  decrementPreviousStatus(draftState: TestedPathType, prevStatus?: 
TestStatusType) {
+    if (!prevStatus) {
+      return;
+    }
+
+    if (prevStatus === TEST_STATUS.PASSED && draftState.success > 0) {
+      draftState.success -= 1;
+    } else if (prevStatus === TEST_STATUS.SKIPPED && draftState.skipped > 0) {
+      draftState.skipped -= 1;
+    } else if (prevStatus === TEST_STATUS.FAILED && draftState.failed > 0) {
+      draftState.failed -= 1;
+    }
+  }
+
+  incrementCurrentStatus(draftState: TestedPathType, status: TestStatusType) {
+    if (status === TEST_STATUS.PASSED) {
+      draftState.success += 1;
+    } else if (status === TEST_STATUS.SKIPPED) {
+      draftState.skipped += 1;
+    } else if (status === TEST_STATUS.FAILED) {
+      draftState.failed += 1;
+    }
+  }
+
+  getResults(): ResultsType {
+    const testedPaths = Array.from(this.testedPaths.keys());
+    const results = flatMap(this.targetPaths, path => 
this.processTargetPath(path, testedPaths));
+
+    return sortBy(results, [5, 1, 0]).reverse();
+  }
+
+  processTargetPath(targetPath: string, testedPaths: string[]): ResultsType {
+    const matchingPaths = this.findMatchingPaths(targetPath, testedPaths);
+
+    if (matchingPaths.length > 0) {
+      return matchingPaths.map(path => this.createResultEntry(path));
+    }
+
+    return [[targetPath, 0, 0, 0, 0, 0]];
+  }
+
+  findMatchingPaths(targetPath: string, testedPaths: string[]): string[] {
+    const regExp = new RegExp(`^${targetPath.replace(/\[.*?\]/g, '[^/]+?')}$`);
+
+    return testedPaths.filter(key => {
+      if (!this.targetPaths.includes(key)) {
+        return regExp.test(key);
+      }
+      return targetPath === key;
+    });
+  }
+
+  createResultEntry(path: string): [string, number, number, number, number, 
number] {
+    const testData = this.testedPaths.get(path);
+    if (!testData) {
+      return [path, 0, 0, 0, 0, 0];
+    }
+
+    const { success, skipped, failed } = testData;
+    const total = success + failed + skipped;
+    const rate = this.toRate(success, total - skipped);
+
+    return [path, total, success, failed, skipped, rate];
+  }
+
+  getTestedPagesResult(results: ResultsType) {
+    const testedList = results.filter(item => !!item[TABLE_COLUMNS.TOTAL]);
+    const failedList = testedList.filter(item => !!item[TABLE_COLUMNS.FAILED]);
+    const skippedList = testedList.filter(item => item[TABLE_COLUMNS.SKIPPED] 
=== item[TABLE_COLUMNS.TOTAL]);
+
+    const failed = failedList.length;
+    const tested = testedList.length;
+    const skipped = skippedList.length;
+    const success = tested - failed - skipped;
+    const testedRate = this.toRate(testedList.length, 
results.length).toFixed(2);
+
+    return `Tested pages: ${tested}/${results.length} (${testedRate}%) 
(${this.toRate(success, tested).toFixed(
+      2
+    )}%, success: ${success}, failed: ${failed}, skipped: ${skipped})`;
+  }
+
+  getTestCasesResult(results: ResultsType) {
+    const stats = results.reduce(
+      (acc, item) => ({
+        total: acc.total + item[TABLE_COLUMNS.TOTAL],
+        failed: acc.failed + item[TABLE_COLUMNS.FAILED],
+        skipped: acc.skipped + item[TABLE_COLUMNS.SKIPPED],
+        success: acc.success + item[TABLE_COLUMNS.SUCCESS]
+      }),
+      { total: 0, failed: 0, skipped: 0, success: 0 }
+    );
+    const rate = this.toRate(stats.success, stats.total - 
stats.skipped).toFixed(2);
+
+    return `Test cases: ${stats.total} (${rate}%, success: ${stats.success}, 
failed: ${stats.failed}, skipped: ${stats.skipped})`;
+  }
+
+  getTableData(results: ResultsType): Array<Array<string | number>> {
+    const header = ['No.', 'Path', 'Test cases', 'Successes', 'Failures', 
'Skipped', 'Success rate'];
+    const rows = results.map((item, index) => {
+      const [path, total, success, failed, skipped, rate] = item;
+      return [index + 1, path, total, success, failed, skipped, 
`${rate.toFixed(2)}%`];
+    });
+
+    return [header, ...rows];
+  }
+
+  async onEnd(result: FullResult) {
+    const results = this.getResults();
+
+    console.log(this.formatTable(results));
+    console.log(this.getTestedPagesResult(results));
+    console.log(this.getTestCasesResult(results));
+    console.log(`Finished the run: ${result.status}`);
+
+    await this.saveResultsToFile(results, result.status);
+  }
+
+  formatTable(results: ResultsType): string {
+    const tableData = this.getTableData(results);
+    if (tableData.length === 0) {
+      return '';
+    }
+
+    const colWidths = tableData[0].map((_, colIndex) => {
+      let maxWidth = 0;
+      for (const row of tableData) {
+        const width = String(row[colIndex]).length;
+        if (width > maxWidth) {
+          maxWidth = width;
+        }
+      }
+      return maxWidth;
+    });
+
+    const lines: string[] = [];
+
+    const header = tableData[0].map((cell, i) => 
String(cell).padEnd(colWidths[i])).join(' │ ');
+    lines.push(header);
+
+    const separator = colWidths.map(width => '─'.repeat(width)).join('─┼─');
+    lines.push(separator);
+
+    for (let i = 1; i < tableData.length; i++) {
+      const row = tableData[i].map((cell, j) => 
String(cell).padEnd(colWidths[j])).join(' │ ');
+      lines.push(row);
+    }
+
+    return lines.join('\n');
+  }
+
+  async saveResultsToFile(results: ResultsType, status: string) {
+    const contents = [
+      this.formatTable(results),
+      this.getTestedPagesResult(results),
+      this.getTestCasesResult(results),
+      `Finished the run: ${status}`
+    ].join('\n');
+
+    try {
+      await fs.mkdir(cfg.outputPath, { recursive: true });
+      await fs.writeFile(`${cfg.outputPath}/${OUTPUT_FILE_NAME}`, contents, 
'utf8');

Review Comment:
   We could use `path.join` to combine the path parts, which could improve 
portability (e.g., on Windows).



##########
zeppelin-web-angular/e2e/reporter.coverage.ts:
##########
@@ -0,0 +1,304 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// @see https://playwright.dev/docs/test-reporters#custom-reporters
+import { FullResult, Reporter, TestCase, TestResult } from 
'@playwright/test/reporter';
+import { promises as fs } from 'fs';
+import { flatMap, sortBy } from 'lodash';
+import { scanDirectory, Results } from 'scandirectory';
+import cfg from './reporter.coverage.config';
+
+const TEST_STATUS = {
+  PASSED: 'passed',
+  SKIPPED: 'skipped',
+  FAILED: 'failed'
+} as const;
+
+type ResultsType = Array<[string, number, number, number, number, number]>;
+type TestStatusType = typeof TEST_STATUS[keyof typeof TEST_STATUS];
+interface TestedPathType {
+  success: number;
+  skipped: number;
+  failed: number;
+}
+
+const OUTPUT_FILE_NAME = 'coverage.log';
+const TABLE_COLUMNS = {
+  TOTAL: 1,
+  SUCCESS: 2,
+  FAILED: 3,
+  SKIPPED: 4
+} as const;
+
+class CoverageReporter implements Reporter {
+  testedPaths = new Map<string, TestedPathType>();
+  testedIds = new Map<string, TestStatusType>();
+  targetPaths: string[] = [];
+
+  async onBegin() {
+    console.log('Coverage reporter starting...');
+    console.log('Root path:', cfg.rootPath);
+
+    const results = await scanDirectory({
+      directory: cfg.rootPath
+    });
+
+    this.targetPaths = this.processScannedFiles(results);
+    console.log('Target paths:', this.targetPaths.length);
+  }
+
+  processScannedFiles(results: Results): string[] {
+    return Object.keys(results)
+      .filter(key => !results[key].directory)
+      .map(key => this.normalizeFilePath(key, results))
+      .filter(key => key !== '.')
+      .filter(key => this.shouldIncludeFile(key));
+  }
+
+  normalizeFilePath(key: string, results: Results): string {
+    if (/index\.tsx?$/.test(key)) {
+      return results[key].parent?.relativePath || '.';
+    }
+    return key.replace(/\.tsx?$/, '');
+  }
+
+  shouldIncludeFile(key: string): boolean {
+    if (cfg.testMatch?.length) {
+      const matchesTest = cfg.testMatch.some(rule => (rule instanceof RegExp ? 
rule.test(key) : rule === key));
+      if (!matchesTest) {
+        return false;
+      }
+    }
+
+    if (cfg.excludes?.length) {
+      const isExcluded = cfg.excludes.some(rule => (rule instanceof RegExp ? 
rule.test(key) : rule === key));
+      if (isExcluded) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  onTestEnd(test: TestCase, result: TestResult) {
+    const status =
+      result.status === TEST_STATUS.PASSED || result.status === 
TEST_STATUS.SKIPPED
+        ? (result.status as TestStatusType)
+        : TEST_STATUS.FAILED;
+    const pages = this.extractPageAnnotations(test);
+    const prevTestStatus = this.testedIds.get(test.id);
+
+    pages.forEach(page => {
+      this.updateTestedPath(page, status, prevTestStatus);
+    });
+
+    this.testedIds.set(test.id, status);
+  }
+
+  extractPageAnnotations(test: TestCase): string[] {
+    const annotations = test.annotations
+      .filter(({ type }) => type === 'page')
+      .map(({ description }) => description)
+      .filter((desc): desc is string => desc !== undefined);
+
+    return Array.from(new Set(annotations));
+  }
+
+  updateTestedPath(page: string, status: TestStatusType, prevStatus?: 
TestStatusType) {
+    if (this.testedPaths.has(page)) {
+      const currentTest = this.testedPaths.get(page)!;
+      const newTest = { ...currentTest };
+      this.decrementPreviousStatus(newTest, prevStatus);
+      this.incrementCurrentStatus(newTest, status);
+      this.testedPaths.set(page, newTest);
+      return;
+    }
+    this.testedPaths.set(page, {
+      success: status === TEST_STATUS.PASSED ? 1 : 0,
+      failed: status === TEST_STATUS.FAILED ? 1 : 0,
+      skipped: status === TEST_STATUS.SKIPPED ? 1 : 0
+    });
+  }
+
+  decrementPreviousStatus(draftState: TestedPathType, prevStatus?: 
TestStatusType) {
+    if (!prevStatus) {
+      return;
+    }
+
+    if (prevStatus === TEST_STATUS.PASSED && draftState.success > 0) {
+      draftState.success -= 1;
+    } else if (prevStatus === TEST_STATUS.SKIPPED && draftState.skipped > 0) {
+      draftState.skipped -= 1;
+    } else if (prevStatus === TEST_STATUS.FAILED && draftState.failed > 0) {
+      draftState.failed -= 1;
+    }
+  }
+
+  incrementCurrentStatus(draftState: TestedPathType, status: TestStatusType) {
+    if (status === TEST_STATUS.PASSED) {
+      draftState.success += 1;
+    } else if (status === TEST_STATUS.SKIPPED) {
+      draftState.skipped += 1;
+    } else if (status === TEST_STATUS.FAILED) {
+      draftState.failed += 1;
+    }
+  }
+
+  getResults(): ResultsType {
+    const testedPaths = Array.from(this.testedPaths.keys());
+    const results = flatMap(this.targetPaths, path => 
this.processTargetPath(path, testedPaths));
+
+    return sortBy(results, [5, 1, 0]).reverse();
+  }
+
+  processTargetPath(targetPath: string, testedPaths: string[]): ResultsType {
+    const matchingPaths = this.findMatchingPaths(targetPath, testedPaths);
+
+    if (matchingPaths.length > 0) {
+      return matchingPaths.map(path => this.createResultEntry(path));
+    }
+
+    return [[targetPath, 0, 0, 0, 0, 0]];
+  }

Review Comment:
   If we change `ResultsType`, then this part could be updated accordingly



##########
zeppelin-web-angular/e2e/utils.ts:
##########
@@ -0,0 +1,139 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { test, TestInfo } from '@playwright/test';
+
+export const PAGES = {
+  // Main App
+  APP: 'src/app/app.component',
+
+  // Core
+  CORE: {
+    DESTROY_HOOK: 'src/app/core/destroy-hook/destroy-hook.component'
+  },
+
+  // Pages
+  PAGES: {
+    LOGIN: 'src/app/pages/login/login.component'
+  },
+
+  // Pages - Workspace
+  WORKSPACE: {
+    MAIN: 'src/app/pages/workspace/workspace.component',
+    CONFIGURATION: 
'src/app/pages/workspace/configuration/configuration.component',
+    CREDENTIAL: 'src/app/pages/workspace/credential/credential.component',
+    HOME: 'src/app/pages/workspace/home/home.component',
+    INTERPRETER: 'src/app/pages/workspace/interpreter/interpreter.component',
+    INTERPRETER_CREATE_REPO:
+      
'src/app/pages/workspace/interpreter/create-repository-modal/create-repository-modal.component',
+    INTERPRETER_ITEM: 
'src/app/pages/workspace/interpreter/item/item.component',
+    JOB_MANAGER: 'src/app/pages/workspace/job-manager/job-manager.component',
+    JOB_STATUS: 
'src/app/pages/workspace/job-manager/job-status/job-status.component',
+    JOB: 'src/app/pages/workspace/job-manager/job/job.component',
+    NOTEBOOK_REPOS: 
'src/app/pages/workspace/notebook-repos/notebook-repos.component',
+    NOTEBOOK_REPOS_ITEM: 
'src/app/pages/workspace/notebook-repos/item/item.component',
+    NOTEBOOK_SEARCH: 
'src/app/pages/workspace/notebook-search/notebook-search.component',
+    NOTEBOOK_SEARCH_RESULT: 
'src/app/pages/workspace/notebook-search/result-item/result-item.component',
+    NOTEBOOK: 'src/app/pages/workspace/notebook/notebook.component',
+    NOTEBOOK_ACTION_BAR: 
'src/app/pages/workspace/notebook/action-bar/action-bar.component',
+    NOTEBOOK_ADD_PARAGRAPH: 
'src/app/pages/workspace/notebook/add-paragraph/add-paragraph.component',
+    NOTEBOOK_INTERPRETER_BINDING: 
'src/app/pages/workspace/notebook/interpreter-binding/interpreter-binding.component',
+    NOTEBOOK_NOTE_FORM: 
'src/app/pages/workspace/notebook/note-form-block/note-form-block.component',
+    NOTEBOOK_PERMISSIONS: 
'src/app/pages/workspace/notebook/permissions/permissions.component',
+    NOTEBOOK_REVISIONS: 
'src/app/pages/workspace/notebook/revisions-comparator/revisions-comparator.component',
+    NOTEBOOK_ELASTIC_INPUT: 
'src/app/pages/workspace/notebook/share/elastic-input/elastic-input.component',
+    NOTEBOOK_SIDEBAR: 
'src/app/pages/workspace/notebook/sidebar/sidebar.component',
+    NOTEBOOK_PARAGRAPH: 
'src/app/pages/workspace/notebook/paragraph/paragraph.component',
+    NOTEBOOK_PARAGRAPH_CODE_EDITOR: 
'src/app/pages/workspace/notebook/paragraph/code-editor/code-editor.component',
+    NOTEBOOK_PARAGRAPH_CONTROL: 
'src/app/pages/workspace/notebook/paragraph/control/control.component',
+    NOTEBOOK_PARAGRAPH_FOOTER: 
'src/app/pages/workspace/notebook/paragraph/footer/footer.component',
+    NOTEBOOK_PARAGRAPH_PROGRESS: 
'src/app/pages/workspace/notebook/paragraph/progress/progress.component',
+    PUBLISHED_PARAGRAPH: 
'src/app/pages/workspace/published/paragraph/paragraph.component',
+    SHARE_DYNAMIC_FORMS: 
'src/app/pages/workspace/share/dynamic-forms/dynamic-forms.component',
+    SHARE_RESULT: 'src/app/pages/workspace/share/result/result.component'
+  },
+
+  // Share
+  SHARE: {
+    ABOUT_ZEPPELIN: 'src/app/share/about-zeppelin/about-zeppelin.component',
+    CODE_EDITOR: 'src/app/share/code-editor/code-editor.component',
+    FOLDER_RENAME: 'src/app/share/folder-rename/folder-rename.component',
+    HEADER: 'src/app/share/header/header.component',
+    NODE_LIST: 'src/app/share/node-list/node-list.component',
+    NOTE_CREATE: 'src/app/share/note-create/note-create.component',
+    NOTE_IMPORT: 'src/app/share/note-import/note-import.component',
+    NOTE_RENAME: 'src/app/share/note-rename/note-rename.component',
+    NOTE_TOC: 'src/app/share/note-toc/note-toc.component',
+    PAGE_HEADER: 'src/app/share/page-header/page-header.component',
+    RESIZE_HANDLE: 'src/app/share/resize-handle/resize-handle.component',
+    SHORTCUT: 'src/app/share/shortcut/shortcut.component',
+    SPIN: 'src/app/share/spin/spin.component'
+  },
+
+  // Visualizations
+  VISUALIZATIONS: {
+    AREA_CHART: 
'src/app/visualizations/area-chart/area-chart-visualization.component',
+    BAR_CHART: 
'src/app/visualizations/bar-chart/bar-chart-visualization.component',
+    LINE_CHART: 
'src/app/visualizations/line-chart/line-chart-visualization.component',
+    PIE_CHART: 
'src/app/visualizations/pie-chart/pie-chart-visualization.component',
+    SCATTER_CHART: 
'src/app/visualizations/scatter-chart/scatter-chart-visualization.component',
+    TABLE: 'src/app/visualizations/table/table-visualization.component',
+    COMMON: {
+      PIVOT_SETTING: 
'src/app/visualizations/common/pivot-setting/pivot-setting.component',
+      SCATTER_SETTING: 
'src/app/visualizations/common/scatter-setting/scatter-setting.component',
+      X_AXIS_SETTING: 
'src/app/visualizations/common/x-axis-setting/x-axis-setting.component'
+    }
+  },
+
+  // Projects
+  PROJECTS: {
+    JSON_VIS: 'projects/helium-vis-example/src/json-vis.component'
+  }
+} as const;
+
+export function testPage(pageName: string, testInfo: TestInfo) {
+  testInfo.annotations.push({
+    type: 'page',
+    description: pageName
+  });
+}
+
+export function testPageBeforeEach(pageName: string) {
+  test.beforeEach(async ({}, testInfo) => {
+    testPage(pageName, testInfo);
+  });
+}
+
+interface PageStructureType {
+  [key: string]: string | PageStructureType;
+}
+
+export function flattenPageComponents(pages: PageStructureType): string[] {
+  const result: string[] = [];
+
+  function flatten(obj: PageStructureType) {
+    for (const key in obj) {
+      if (typeof obj[key] === 'string') {
+        result.push(obj[key]);
+      } else if (typeof obj[key] === 'object' && obj[key] !== null) {
+        flatten(obj[key]);
+      }
+    }

Review Comment:
   How about using `Object.values`? That way, `value` will have a stricter type.
   ```suggestion
       for (const value of Object.values(obj)) {
         if (typeof value === 'string') {
           result.push(value);
         } else if (typeof value === 'object' && value !== null) {
           flatten(value);
         }
       }
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


Reply via email to