@allurereport/web-awesome 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/multi/173.app-b18cce138691927e8759.js +1 -0
  2. package/dist/multi/174.app-b18cce138691927e8759.js +1 -0
  3. package/dist/multi/252.app-b18cce138691927e8759.js +1 -0
  4. package/dist/multi/282.app-b18cce138691927e8759.js +1 -0
  5. package/dist/multi/29.app-b18cce138691927e8759.js +1 -0
  6. package/dist/multi/310.app-b18cce138691927e8759.js +1 -0
  7. package/dist/multi/416.app-b18cce138691927e8759.js +1 -0
  8. package/dist/multi/507.app-b18cce138691927e8759.js +1 -0
  9. package/dist/multi/527.app-b18cce138691927e8759.js +1 -0
  10. package/dist/multi/600.app-b18cce138691927e8759.js +1 -0
  11. package/dist/multi/605.app-b18cce138691927e8759.js +1 -0
  12. package/dist/multi/638.app-b18cce138691927e8759.js +1 -0
  13. package/dist/multi/672.app-b18cce138691927e8759.js +1 -0
  14. package/dist/multi/686.app-b18cce138691927e8759.js +1 -0
  15. package/dist/multi/725.app-b18cce138691927e8759.js +1 -0
  16. package/dist/multi/741.app-b18cce138691927e8759.js +1 -0
  17. package/dist/multi/749.app-b18cce138691927e8759.js +1 -0
  18. package/dist/multi/755.app-b18cce138691927e8759.js +1 -0
  19. package/dist/multi/894.app-b18cce138691927e8759.js +1 -0
  20. package/dist/multi/943.app-b18cce138691927e8759.js +1 -0
  21. package/dist/multi/980.app-b18cce138691927e8759.js +1 -0
  22. package/dist/multi/app-b18cce138691927e8759.js +2 -0
  23. package/dist/multi/manifest.json +25 -25
  24. package/dist/multi/{styles-468416ffee9a9dea6cae.css → styles-a4f65de86208f79dd2be.css} +8 -8
  25. package/dist/single/app-733f473da7b51f98876d.js +2 -0
  26. package/dist/single/manifest.json +1 -1
  27. package/package.json +14 -14
  28. package/src/components/Footer/FooterVersion.tsx +5 -10
  29. package/src/components/Footer/index.tsx +7 -1
  30. package/src/components/Footer/styles.scss +6 -0
  31. package/src/components/Header/CiInfo/index.tsx +17 -13
  32. package/src/components/HeaderControls/index.tsx +1 -3
  33. package/src/components/KeyboardShortcuts/styles.scss +5 -5
  34. package/src/components/MainReport/styles.scss +0 -21
  35. package/src/components/Metadata/index.tsx +27 -6
  36. package/src/components/Metadata/styles.scss +12 -0
  37. package/src/components/ReportBody/index.tsx +2 -11
  38. package/src/components/ReportBody/styles.scss +0 -21
  39. package/src/components/ReportHeader/index.tsx +25 -13
  40. package/src/components/ReportMetadata/index.tsx +35 -4
  41. package/src/components/SplitLayout/index.tsx +1 -1
  42. package/src/components/SplitLayout/styles.scss +4 -1
  43. package/src/components/TestResult/TrRetriesView/TrRetriesItem.tsx +27 -1
  44. package/src/components/TestResult/TrRetriesView/styles.scss +17 -7
  45. package/src/components/TestResult/TrSetup/index.tsx +1 -1
  46. package/src/components/TestResult/TrSteps/TrBodyItems.tsx +5 -2
  47. package/src/components/TestResult/TrSteps/TrStep.tsx +6 -2
  48. package/src/components/TestResult/TrSteps/index.tsx +2 -3
  49. package/src/components/TestResult/TrTeardown/index.tsx +1 -1
  50. package/src/components/Tree/index.tsx +26 -1
  51. package/src/locales/ar.json +1 -0
  52. package/src/locales/az.json +1 -0
  53. package/src/locales/de.json +1 -0
  54. package/src/locales/en.json +1 -0
  55. package/src/locales/es.json +1 -0
  56. package/src/locales/fr.json +1 -0
  57. package/src/locales/he.json +1 -0
  58. package/src/locales/hy.json +1 -0
  59. package/src/locales/it.json +1 -0
  60. package/src/locales/ja.json +1 -0
  61. package/src/locales/ka.json +1 -0
  62. package/src/locales/kr.json +1 -0
  63. package/src/locales/nl.json +1 -0
  64. package/src/locales/pl.json +1 -0
  65. package/src/locales/pt.json +1 -0
  66. package/src/locales/ru.json +1 -0
  67. package/src/locales/sv.json +1 -0
  68. package/src/locales/tr.json +1 -0
  69. package/src/locales/uk.json +1 -0
  70. package/src/locales/zh-TW.json +1 -0
  71. package/src/locales/zh.json +1 -0
  72. package/src/stores/locale.ts +4 -2
  73. package/src/stores/treeSort.ts +7 -1
  74. package/src/utils/atSeparator.ts +4 -0
  75. package/src/utils/time.ts +2 -1
  76. package/src/utils/treeFilters.ts +15 -4
  77. package/test/components/Footer.test.tsx +26 -0
  78. package/test/components/Header/CiInfo.test.tsx +48 -0
  79. package/test/components/HeaderControls.test.tsx +28 -0
  80. package/test/components/ReportHeader.test.tsx +77 -0
  81. package/test/components/ReportMetadata.test.tsx +131 -0
  82. package/test/components/TestResult/TrRetriesItem.test.tsx +163 -0
  83. package/test/components/TestResult/TrSteps.test.tsx +45 -10
  84. package/test/stores/treeSort.test.ts +58 -0
  85. package/test/utils/time.test.ts +52 -0
  86. package/test/utils/treeFilters.test.ts +104 -0
  87. package/types.d.ts +22 -0
  88. package/webpack.config.js +9 -7
  89. package/dist/multi/173.app-d36b0855e3e7a53eeee9.js +0 -1
  90. package/dist/multi/174.app-d36b0855e3e7a53eeee9.js +0 -1
  91. package/dist/multi/252.app-d36b0855e3e7a53eeee9.js +0 -1
  92. package/dist/multi/282.app-d36b0855e3e7a53eeee9.js +0 -1
  93. package/dist/multi/29.app-d36b0855e3e7a53eeee9.js +0 -1
  94. package/dist/multi/310.app-d36b0855e3e7a53eeee9.js +0 -1
  95. package/dist/multi/416.app-d36b0855e3e7a53eeee9.js +0 -1
  96. package/dist/multi/507.app-d36b0855e3e7a53eeee9.js +0 -1
  97. package/dist/multi/527.app-d36b0855e3e7a53eeee9.js +0 -1
  98. package/dist/multi/600.app-d36b0855e3e7a53eeee9.js +0 -1
  99. package/dist/multi/605.app-d36b0855e3e7a53eeee9.js +0 -1
  100. package/dist/multi/638.app-d36b0855e3e7a53eeee9.js +0 -1
  101. package/dist/multi/672.app-d36b0855e3e7a53eeee9.js +0 -1
  102. package/dist/multi/686.app-d36b0855e3e7a53eeee9.js +0 -1
  103. package/dist/multi/725.app-d36b0855e3e7a53eeee9.js +0 -1
  104. package/dist/multi/741.app-d36b0855e3e7a53eeee9.js +0 -1
  105. package/dist/multi/749.app-d36b0855e3e7a53eeee9.js +0 -1
  106. package/dist/multi/755.app-d36b0855e3e7a53eeee9.js +0 -1
  107. package/dist/multi/894.app-d36b0855e3e7a53eeee9.js +0 -1
  108. package/dist/multi/943.app-d36b0855e3e7a53eeee9.js +0 -1
  109. package/dist/multi/980.app-d36b0855e3e7a53eeee9.js +0 -1
  110. package/dist/multi/app-d36b0855e3e7a53eeee9.js +0 -2
  111. package/dist/single/app-62171f5f51b5954a787c.js +0 -2
  112. /package/dist/multi/{121.app-d36b0855e3e7a53eeee9.js → 121.app-b18cce138691927e8759.js} +0 -0
  113. /package/dist/multi/{779.app-d36b0855e3e7a53eeee9.js → 779.app-b18cce138691927e8759.js} +0 -0
  114. /package/dist/multi/{app-d36b0855e3e7a53eeee9.js.LICENSE.txt → app-b18cce138691927e8759.js.LICENSE.txt} +0 -0
  115. /package/dist/single/{app-62171f5f51b5954a787c.js.LICENSE.txt → app-733f473da7b51f98876d.js.LICENSE.txt} +0 -0
@@ -4,6 +4,7 @@ import type { FunctionalComponent } from "preact";
4
4
  import { useState } from "preact/hooks";
5
5
  import type { AwesomeTestResult } from "types";
6
6
 
7
+ import { hasErrorDiff } from "@/components/TestResult/bodyItems";
7
8
  import { TrError } from "@/components/TestResult/TrError";
8
9
  import { useI18n } from "@/stores/locale";
9
10
  import { navigateToTestResult } from "@/stores/router";
@@ -22,13 +23,15 @@ export const TrRetriesItem: FunctionalComponent<TrRetriesItemProps> = ({ testRes
22
23
  const [isOpened, setIsOpen] = useState(false);
23
24
 
24
25
  const { t } = useI18n("ui");
26
+ const { t: controls } = useI18n("controls");
25
27
 
26
28
  const retryTitlePrefix = t("attempt", { attempt, total: totalAttempts });
27
29
  const convertedStop = stop ? timestampToDate(stop) : undefined;
28
30
  const retryTitle = convertedStop ? `${retryTitlePrefix} – ${convertedStop}` : retryTitlePrefix;
29
31
 
30
32
  const formattedDuration = typeof duration === "number" ? formatDuration(duration) : undefined;
31
- const hasErrorDetails = Boolean(error?.trace || error?.message);
33
+ const errorPreview = getErrorPreview(error, controls("comparison"));
34
+ const hasErrorDetails = Boolean(errorPreview);
32
35
 
33
36
  return (
34
37
  <div data-testid="test-result-retries-item">
@@ -45,6 +48,16 @@ export const TrRetriesItem: FunctionalComponent<TrRetriesItemProps> = ({ testRes
45
48
  <Text data-testid="test-result-retries-item-text" className={styles["test-result-retries-item-text"]}>
46
49
  {retryTitle}
47
50
  </Text>
51
+ {errorPreview && (
52
+ <Text
53
+ data-testid="test-result-retries-item-error-preview"
54
+ type="ui"
55
+ size="s"
56
+ className={styles["test-result-retries-item-error-preview"]}
57
+ >
58
+ {errorPreview}
59
+ </Text>
60
+ )}
48
61
  <div className={styles["test-result-retries-item-info"]}>
49
62
  {Boolean(formattedDuration) && (
50
63
  <Text type="ui" size={"s"} className={styles["item-time"]}>
@@ -70,3 +83,16 @@ export const TrRetriesItem: FunctionalComponent<TrRetriesItemProps> = ({ testRes
70
83
  </div>
71
84
  );
72
85
  };
86
+
87
+ const getErrorPreview = (error: AwesomeTestResult["error"], diffPreview: string) => {
88
+ const message = error?.message?.trim();
89
+ if (message) return message;
90
+
91
+ const tracePreview = error?.trace
92
+ ?.split(/\r?\n/)
93
+ .map((line) => line.trim())
94
+ .find(Boolean);
95
+ if (tracePreview) return tracePreview;
96
+
97
+ if (hasErrorDiff(error)) return diffPreview;
98
+ };
@@ -20,13 +20,13 @@
20
20
 
21
21
  .test-result-retries-item-wrap {
22
22
  transition: background-color 300ms;
23
- display: flex;
23
+ display: grid;
24
+ grid-template-columns: auto auto minmax(0, 1fr) max-content;
24
25
  gap: 4px;
25
- justify-content: space-between;
26
26
  border-radius: 6px;
27
27
  padding: 4px;
28
28
  width: 100%;
29
- align-items: flex-start;
29
+ align-items: center;
30
30
 
31
31
  &:hover {
32
32
  background: var(--color-row-bg-hover);
@@ -34,20 +34,30 @@
34
34
  }
35
35
 
36
36
  .test-result-retries-item-text {
37
- padding-top: 2px;
37
+ flex: 0 0 auto;
38
+ }
39
+
40
+ .test-result-retries-item-error-preview {
41
+ flex: 1 1 auto;
42
+ color: var(--on-text-secondary);
43
+ overflow: hidden;
44
+ text-overflow: ellipsis;
45
+ white-space: nowrap;
46
+ min-width: 0;
38
47
  }
39
48
 
40
49
  .test-result-retries-item-info {
41
50
  display: flex;
42
51
  gap: 4px;
43
52
  align-items: center;
44
- margin-left: auto;
53
+ justify-content: flex-end;
54
+ margin-left: 8px;
45
55
  }
46
56
 
47
57
  .item-time {
48
- margin-left: auto;
49
- line-height: 20px;
50
58
  color: var(--color-text-secondary);
59
+ line-height: 16px;
60
+ white-space: nowrap;
51
61
  }
52
62
 
53
63
  .test-result-retries-item-content {
@@ -46,7 +46,7 @@ export const TrSetup: FunctionalComponent<TrSetupProps> = ({ setup, id }) => {
46
46
  <div className={styles["test-result-steps-root"]}>
47
47
  {setup?.map((fixture, key) => (
48
48
  <div className={styles["test-result-step-root"]} key={fixture.id}>
49
- <TrStep item={fixtureResultToTrStepItem(fixture)} stepIndex={key + 1} />
49
+ <TrStep item={fixtureResultToTrStepItem(fixture)} stepIndex={key + 1} isTopLevel={true} />
50
50
  </div>
51
51
  ))}
52
52
  </div>
@@ -20,15 +20,18 @@ const getBodyItemKey = (item: TrBodyItem, index: number) => {
20
20
 
21
21
  export type TrBodyItemsProps = {
22
22
  bodyItems: TrBodyItem[];
23
+ isTopLevel?: boolean;
23
24
  };
24
25
 
25
- export const TrBodyItems: FunctionalComponent<TrBodyItemsProps> = ({ bodyItems }) => {
26
+ export const TrBodyItems: FunctionalComponent<TrBodyItemsProps> = ({ bodyItems, isTopLevel }) => {
26
27
  return (
27
28
  <>
28
29
  {bodyItems.map((item, index) => {
29
30
  switch (item.type) {
30
31
  case "step":
31
- return <TrStep item={item} stepIndex={index + 1} key={getBodyItemKey(item, index)} />;
32
+ return (
33
+ <TrStep item={item} stepIndex={index + 1} isTopLevel={isTopLevel} key={getBodyItemKey(item, index)} />
34
+ );
32
35
  case "attachment":
33
36
  return <TrAttachment item={item} stepIndex={index + 1} key={getBodyItemKey(item, index)} />;
34
37
  case "error":
@@ -16,6 +16,7 @@ import {
16
16
  collectExpandableStepNodes,
17
17
  hasStepContent,
18
18
  getStepTreeExpansionPolicy,
19
+ isOpenByDefaultForPolicy,
19
20
  isStepOpenedByDefault,
20
21
  type SubtreeNode,
21
22
  } from "@/components/TestResult/TrSteps/stepTreeExpansion";
@@ -70,7 +71,8 @@ export const TrStepsContent = (props: { item: TrStepItem }) => {
70
71
  export const TrStep: FunctionComponent<{
71
72
  item: TrStepItem;
72
73
  stepIndex?: number;
73
- }> = ({ item, stepIndex }) => {
74
+ isTopLevel?: boolean;
75
+ }> = ({ item, stepIndex, isTopLevel }) => {
74
76
  const { item: stepData, bodyItems, suppressInlineError } = item;
75
77
  const inlineError = {
76
78
  message: stepData.message ?? stepData.error?.message,
@@ -85,7 +87,9 @@ export const TrStep: FunctionComponent<{
85
87
  );
86
88
  const policy = getStepTreeExpansionPolicy();
87
89
  const hasContent = hasStepContent(item);
88
- const openedByDefault = isStepOpenedByDefault(policy, stepData.status, bodyItems);
90
+ const openedByDefault = isTopLevel
91
+ ? isOpenByDefaultForPolicy(policy, true)
92
+ : isStepOpenedByDefault(policy, stepData.status, bodyItems);
89
93
  const isOpened = isTreeOpened(stepData.stepId, openedByDefault);
90
94
  const expandableDescendantNodes = collectExpandableStepNodes(bodyItems, policy);
91
95
  const hasExpandableDescendants = expandableDescendantNodes.length > 0;
@@ -8,7 +8,6 @@ import { TrDropdown } from "@/components/TestResult/TrDropdown";
8
8
  import {
9
9
  collectExpandableStepNodes,
10
10
  getStepTreeExpansionPolicy,
11
- hasFailedStepContext,
12
11
  isOpenByDefaultForPolicy,
13
12
  isSubtreeFirstLevelOnlyOpened,
14
13
  type SubtreeNode,
@@ -35,7 +34,7 @@ export type TrStepsProps = {
35
34
  export const TrSteps: FunctionalComponent<TrStepsProps> = ({ bodyItems, id }) => {
36
35
  const stepsId = typeof id === "string" ? `${id}-steps` : null;
37
36
  const policy = getStepTreeExpansionPolicy();
38
- const isRootOpenedByDefault = isOpenByDefaultForPolicy(policy, hasFailedStepContext(bodyItems));
37
+ const isRootOpenedByDefault = isOpenByDefaultForPolicy(policy, true);
39
38
  const isOpened = stepsId !== null ? isTreeOpened(stepsId, isRootOpenedByDefault) : isRootOpenedByDefault;
40
39
  const expandableTreeNodes = collectExpandableStepNodes(bodyItems, policy);
41
40
  const hasChildren = stepsId !== null && bodyItems.length > 0;
@@ -118,7 +117,7 @@ export const TrSteps: FunctionalComponent<TrStepsProps> = ({ bodyItems, id }) =>
118
117
  />
119
118
  {isOpened && (
120
119
  <div data-testid="test-result-steps-root" className={styles["test-result-steps-root"]}>
121
- <TrBodyItems bodyItems={bodyItems} />
120
+ <TrBodyItems bodyItems={bodyItems} isTopLevel={true} />
122
121
  </div>
123
122
  )}
124
123
  </div>
@@ -47,7 +47,7 @@ export const TrTeardown: FunctionalComponent<TrTeardownProps> = ({ teardown, id
47
47
  <div className={styles["test-result-steps-root"]}>
48
48
  {teardown?.map((fixture, key) => (
49
49
  <div className={styles["test-result-step-root"]} key={fixture.id}>
50
- <TrStep item={fixtureResultToTrStepItem(fixture)} stepIndex={key + 1} />
50
+ <TrStep item={fixtureResultToTrStepItem(fixture)} stepIndex={key + 1} isTopLevel={true} />
51
51
  </div>
52
52
  ))}
53
53
  </div>
@@ -58,9 +58,34 @@ export const TreeList = () => {
58
58
  }
59
59
 
60
60
  const flatNode = getFlatTreeNode(focusedId);
61
- scrollFocusIntoView(node, { kind: flatNode?.kind });
61
+ const kind = flatNode?.kind;
62
+
63
+ // For group/env nodes always pin to top (even when already visible in viewport)
64
+ if (kind === "group" || kind === "env") {
65
+ (node as HTMLElement).scrollIntoView({ block: "start", inline: "nearest" });
66
+ } else {
67
+ scrollFocusIntoView(node, { kind });
68
+ }
62
69
  }, [focusedId]);
63
70
 
71
+ useLayoutEffect(() => {
72
+ if (!trId || focusedId) {
73
+ return;
74
+ }
75
+
76
+ // Use flatTree to find the scoped node id — avoids duplicate id issues in multi-env trees
77
+ const flatNode = flatTree.value.find((n) => n.testResultId === trId || n.id === trId);
78
+ const node = flatNode
79
+ ? (document.querySelector(`[data-tree-node-id="${flatNode.id}"]`) as HTMLElement | null)
80
+ : document.getElementById(trId);
81
+
82
+ if (!node) {
83
+ return;
84
+ }
85
+
86
+ scrollFocusIntoView(node, { kind: "leaf" });
87
+ }, [trId]);
88
+
64
89
  const localizers = useMemo(
65
90
  () => ({
66
91
  tooltip: (key: string, options: Record<string, string>) => tooltip(`description.${key}`, options),
@@ -151,6 +151,7 @@
151
151
  "copy-email": "نسخ البريد الإلكتروني",
152
152
  "attempt": "المحاولة {{attempt}} من {{total}}",
153
153
  "at": "في",
154
+ "generated": "تم الإنشاء",
154
155
  "variables": "المتغيرات",
155
156
  "openPwTrace": "فتح تتبع Playwright",
156
157
  "finishedAtOriginal": "{{formattedCreatedAt}}، برمز خروج {{original}}",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "E-poçtu kopyala",
152
152
  "attempt": "Cəhd {{attempt}} / {{total}}",
153
153
  "at": "tarixində",
154
+ "generated": "Yaradıldı",
154
155
  "variables": "Dəyişənlər",
155
156
  "openPwTrace": "Playwright Trace-i aç",
156
157
  "pwTracePopupBlocked": "Brauzer Playwright Trace Viewer-i açmağı blokladı.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "E-Mail kopieren",
152
152
  "attempt": "Versuch {{attempt}} von {{total}}",
153
153
  "at": "bei",
154
+ "generated": "Generiert",
154
155
  "variables": "Variablen",
155
156
  "openPwTrace": "Playwright Trace öffnen",
156
157
  "pwTracePopupBlocked": "Der Browser hat das Öffnen des Playwright Trace Viewer blockiert.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "Copy email",
152
152
  "attempt": "Attempt {{attempt}} of {{total}}",
153
153
  "at": "at",
154
+ "generated": "Generated",
154
155
  "variables": "Variables",
155
156
  "openPwTrace": "Open Playwright Trace",
156
157
  "pwTracePopupBlocked": "Browser blocked opening Playwright Trace Viewer.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "Copiar correo",
152
152
  "attempt": "Intento {{attempt}} de {{total}}",
153
153
  "at": "a las",
154
+ "generated": "Generado",
154
155
  "variables": "Variables",
155
156
  "openPwTrace": "Abrir Playwright Trace",
156
157
  "pwTracePopupBlocked": "El navegador bloqueó la apertura de Playwright Trace Viewer.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "Copier l'e-mail",
152
152
  "attempt": "Tentative {{attempt}} sur {{total}}",
153
153
  "at": "à",
154
+ "generated": "Généré",
154
155
  "variables": "Variables",
155
156
  "openPwTrace": "Ouvrir Playwright Trace",
156
157
  "pwTracePopupBlocked": "Le navigateur a bloqué l’ouverture de Playwright Trace Viewer.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "העתק אימייל",
152
152
  "attempt": "ניסיון {{attempt}} מתוך {{total}}",
153
153
  "at": "ב-",
154
+ "generated": "נוצר",
154
155
  "variables": "משתנים",
155
156
  "openPwTrace": "פתח Playwright Trace",
156
157
  "pwTracePopupBlocked": "הדפדפן חסם את פתיחת Playwright Trace Viewer.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "Պատճենել էլ. փոստը",
152
152
  "attempt": "Փորձ {{attempt}}-ից {{total}}-ը",
153
153
  "at": "է",
154
+ "generated": "Ստեղծվել է",
154
155
  "variables": "Փոփոխականներ",
155
156
  "openPwTrace": "Բացել Playwright Trace",
156
157
  "pwTracePopupBlocked": "Բրաուզերը արգելափակեց Playwright Trace Viewer-ի բացումը։",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "Copia email",
152
152
  "attempt": "Tentativo {{attempt}} di {{total}}",
153
153
  "at": "a",
154
+ "generated": "Generato",
154
155
  "variables": "Variabili",
155
156
  "openPwTrace": "Apri Playwright Trace",
156
157
  "pwTracePopupBlocked": "Il browser ha bloccato l'apertura di Playwright Trace Viewer.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "メールをコピー",
152
152
  "attempt": "試行 {{attempt}} 回目(全 {{total}} 回)",
153
153
  "at": "時",
154
+ "generated": "生成日時",
154
155
  "variables": "変数",
155
156
  "openPwTrace": "Playwright Traceを開く",
156
157
  "pwTracePopupBlocked": "ブラウザーが Playwright Trace Viewer の表示をブロックしました。",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "ელფოსტის კოპირება",
152
152
  "attempt": "მცდელობა {{attempt}} {{total}}-დან",
153
153
  "at": "ზე",
154
+ "generated": "გენერირებულია",
154
155
  "variables": "ცვლადები",
155
156
  "openPwTrace": "გახსენი Playwright Trace",
156
157
  "pwTracePopupBlocked": "ბრაუზერმა დაბლოკა Playwright Trace Viewer-ის გახსნა.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "이메일 복사",
152
152
  "attempt": "시도 {{attempt}}/{{total}}",
153
153
  "at": "에서",
154
+ "generated": "생성됨",
154
155
  "variables": "변수",
155
156
  "openPwTrace": "Playwright Trace 열기",
156
157
  "pwTracePopupBlocked": "브라우저가 Playwright Trace Viewer 열기를 차단했습니다.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "E-mail kopiëren",
152
152
  "attempt": "Poging {{attempt}} van {{total}}",
153
153
  "at": "om",
154
+ "generated": "Gegenereerd",
154
155
  "variables": "Variabelen",
155
156
  "openPwTrace": "Open Playwright Trace",
156
157
  "pwTracePopupBlocked": "De browser heeft het openen van Playwright Trace Viewer geblokkeerd.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "Kopiuj e-mail",
152
152
  "attempt": "Próba {{attempt}} z {{total}}",
153
153
  "at": "w",
154
+ "generated": "Wygenerowano",
154
155
  "variables": "Zmienne",
155
156
  "openPwTrace": "Otwórz Playwright Trace",
156
157
  "pwTracePopupBlocked": "Przeglądarka zablokowała otwarcie Playwright Trace Viewer.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "Copiar e-mail",
152
152
  "attempt": "Tentativa {{attempt}} de {{total}}",
153
153
  "at": "em",
154
+ "generated": "Gerado",
154
155
  "variables": "Variáveis",
155
156
  "openPwTrace": "Abrir Playwright Trace",
156
157
  "pwTracePopupBlocked": "O navegador bloqueou a abertura do Playwright Trace Viewer.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "Скопировать email",
152
152
  "attempt": "Попытка {{attempt}} из {{total}}",
153
153
  "at": "в",
154
+ "generated": "Сгенерировано",
154
155
  "variables": "Переменные",
155
156
  "openPwTrace": "Открыть Playwright Trace",
156
157
  "pwTracePopupBlocked": "Браузер заблокировал открытие Playwright Trace Viewer.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "Kopiera e-post",
152
152
  "attempt": "Försök {{attempt}} av {{total}}",
153
153
  "at": "vid",
154
+ "generated": "Genererad",
154
155
  "variables": "Variabler",
155
156
  "openPwTrace": "Öppna Playwright Trace",
156
157
  "pwTracePopupBlocked": "Webbläsaren blockerade öppning av Playwright Trace Viewer.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "E-postayı kopyala",
152
152
  "attempt": "Deneme {{attempt}} / {{total}}",
153
153
  "at": "tarihinde",
154
+ "generated": "Oluşturuldu",
154
155
  "variables": "Değişkenler",
155
156
  "openPwTrace": "Playwright Trace Aç",
156
157
  "pwTracePopupBlocked": "Tarayıcı Playwright Trace Viewer’ın açılmasını engelledi.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "Скопіювати email",
152
152
  "attempt": "Спроба {{attempt}} з {{total}}",
153
153
  "at": "в",
154
+ "generated": "Згенеровано",
154
155
  "variables": "Змінні",
155
156
  "openPwTrace": "Відкрити Playwright Trace",
156
157
  "pwTracePopupBlocked": "Браузер заблокував відкриття Playwright Trace Viewer.",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "複製電子郵件",
152
152
  "attempt": "第 {{attempt}} 次嘗試,共 {{total}} 次",
153
153
  "at": "於",
154
+ "generated": "已產生",
154
155
  "variables": "變數",
155
156
  "openPwTrace": "開啟 Playwright Trace",
156
157
  "pwTracePopupBlocked": "瀏覽器封鎖了開啟 Playwright Trace Viewer。",
@@ -151,6 +151,7 @@
151
151
  "copy-email": "复制邮箱",
152
152
  "attempt": "第 {{attempt}} 次尝试,共 {{total}} 次",
153
153
  "at": "在",
154
+ "generated": "已生成",
154
155
  "variables": "变量",
155
156
  "openPwTrace": "打开 Playwright Trace",
156
157
  "pwTracePopupBlocked": "浏览器阻止了打开 Playwright Trace Viewer。",
@@ -10,6 +10,8 @@ import { computed, signal } from "@preact/signals";
10
10
  import i18next, { type TOptions } from "i18next";
11
11
  import type { AwesomeReportOptions } from "types";
12
12
 
13
+ import { ensureAtSeparator } from "@/utils/atSeparator";
14
+
13
15
  const namespaces = [
14
16
  "empty",
15
17
  "execution",
@@ -112,7 +114,7 @@ export const waitForI18next = i18next
112
114
  if (override?.includeAtSeparator === false || override?.stripComma) {
113
115
  return formatted.replace(",", "");
114
116
  }
115
- return formatted.replace(",", ` ${i18next.t("ui:at")}`);
117
+ return ensureAtSeparator(formatted, i18next.t("ui:at"));
116
118
  },
117
119
  );
118
120
  i18next.services.formatter.add(
@@ -133,7 +135,7 @@ export const waitForI18next = i18next
133
135
  if (override?.includeAtSeparator === false || override?.stripComma) {
134
136
  return formatted.replace(",", "");
135
137
  }
136
- return formatted.replace(",", ` ${i18next.t("ui:at")}`);
138
+ return ensureAtSeparator(formatted, i18next.t("ui:at"));
137
139
  },
138
140
  );
139
141
  i18next.services.formatter.add("format_duration", (value: number) => {
@@ -1,6 +1,8 @@
1
- import { getParamValue, hasParam, setParams } from "@allurereport/web-commons";
1
+ import { getParamValue, getReportOptions, hasParam, setParams } from "@allurereport/web-commons";
2
2
  import { computed, effect, signal } from "@preact/signals";
3
3
 
4
+ import type { AwesomeReportOptions } from "../../types.js";
5
+
4
6
  export type SortByDirection = "asc" | "desc";
5
7
  export type SortByField = "order" | "duration" | "status" | "name";
6
8
  export type SortBy = `${SortByField},${SortByDirection}`;
@@ -34,6 +36,10 @@ const getInitialSortBy = (): SortBy => {
34
36
  if (stored && validateSortBy(stored.toLowerCase())) {
35
37
  return stored.toLowerCase() as SortBy;
36
38
  }
39
+ const { defaultSortBy } = getReportOptions<AwesomeReportOptions>() ?? {};
40
+ if (defaultSortBy && validateSortBy(defaultSortBy.toLowerCase())) {
41
+ return defaultSortBy.toLowerCase() as SortBy;
42
+ }
37
43
  return DEFAULT_SORT_BY;
38
44
  };
39
45
 
@@ -0,0 +1,4 @@
1
+ export const ensureAtSeparator = (formatted: string, atWord: string): string => {
2
+ const atSeparator = ` ${atWord} `;
3
+ return formatted.includes(atSeparator) ? formatted : formatted.replace(",", ` ${atWord}`);
4
+ };
package/src/utils/time.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { getLocaleDateTimeOverride } from "@allurereport/web-commons";
2
2
 
3
3
  import { currentLocale, currentLocaleIso, useI18n } from "@/stores/locale";
4
+ import { ensureAtSeparator } from "@/utils/atSeparator";
4
5
 
5
6
  const defaultOptions: Intl.DateTimeFormatOptions = {
6
7
  month: "numeric",
@@ -29,5 +30,5 @@ export const timestampToDate = (timestamp: number, options = defaultOptions) =>
29
30
  return formatted.replace(",", "");
30
31
  }
31
32
 
32
- return formatted.replace(",", ` ${t("at")}`);
33
+ return ensureAtSeparator(formatted, t("at"));
33
34
  };
@@ -1,4 +1,4 @@
1
- import type { Comparator, DefaultTreeGroup, Statistic, TestStatus, TreeLeaf } from "@allurereport/core-api";
1
+ import type { Comparator, Statistic, TestStatus, TreeLeaf } from "@allurereport/core-api";
2
2
  import {
3
3
  alphabetically,
4
4
  andThen,
@@ -38,16 +38,18 @@ const leafComparatorByTreeSortBy = (sortBy: SortBy = "status,asc"): Comparator<T
38
38
  }
39
39
  };
40
40
 
41
- const groupComparatorByTreeSortBy = (sortBy: SortBy = "status,asc"): Comparator<DefaultTreeGroup> => {
42
- const typedCompareBy = compareBy<DefaultTreeGroup>;
41
+ const groupComparatorByTreeSortBy = (sortBy: SortBy = "status,asc"): Comparator<AwesomeRecursiveTree> => {
42
+ const typedCompareBy = compareBy<AwesomeRecursiveTree>;
43
43
  switch (sortBy) {
44
44
  case "name,desc":
45
45
  case "name,asc":
46
46
  return typedCompareBy("name", alphabetically());
47
47
  case "order,desc":
48
48
  case "order,asc":
49
+ return typedCompareBy("groupOrder", ordinal());
49
50
  case "duration,desc":
50
51
  case "duration,asc":
52
+ return typedCompareBy("duration", ordinal());
51
53
  case "status,desc":
52
54
  case "status,asc":
53
55
  return typedCompareBy("statistic", byStatistic());
@@ -76,7 +78,7 @@ export const leafComparator = (sortBy: SortBy = "status,asc"): Comparator<TreeLe
76
78
  return withDirection(cmp, sortBy);
77
79
  };
78
80
 
79
- export const groupComparator = (sortBy: SortBy = "status,asc"): Comparator<DefaultTreeGroup> => {
81
+ export const groupComparator = (sortBy: SortBy = "status,asc"): Comparator<AwesomeRecursiveTree> => {
80
82
  const cmp = groupComparatorByTreeSortBy(sortBy);
81
83
 
82
84
  return withDirection(cmp, sortBy);
@@ -150,11 +152,20 @@ export const createRecursiveTree = (payload: {
150
152
  incrementStatistic(statistic, status);
151
153
  });
152
154
 
155
+ const duration =
156
+ leaves.reduce((acc, leaf) => acc + (leaf.duration ?? 0), 0) + trees.reduce((acc, rt) => acc + rt.duration, 0);
157
+
158
+ const leafMinOrder = leaves.reduce((acc, leaf) => Math.min(acc, leaf.groupOrder ?? Infinity), Infinity);
159
+ const treeMinOrder = trees.reduce((acc, rt) => Math.min(acc, rt.groupOrder), Infinity);
160
+ const groupOrder = Math.min(leafMinOrder, treeMinOrder);
161
+
153
162
  return {
154
163
  ...group,
155
164
  statistic,
156
165
  leaves,
157
166
  trees: trees.sort(groupComparator(sortBy)),
167
+ duration,
168
+ groupOrder: isFinite(groupOrder) ? groupOrder : 0,
158
169
  };
159
170
  };
160
171
 
@@ -0,0 +1,26 @@
1
+ import { render, screen } from "@testing-library/preact";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { Footer } from "@/components/Footer";
5
+
6
+ vi.mock("@/components/Footer/FooterLogo", () => ({
7
+ FooterLogo: () => <div data-testid="footer-logo" />,
8
+ }));
9
+
10
+ vi.mock("@allurereport/web-components", () => ({
11
+ LanguagePicker: () => <div data-testid="footer-language-picker" />,
12
+ }));
13
+
14
+ vi.mock("@/components/Footer/FooterVersion", () => ({
15
+ FooterVersion: () => <div data-testid="footer-version">Generated May 10, 2026 Ver: 3.8.2</div>,
16
+ }));
17
+
18
+ describe("components > Footer", () => {
19
+ it("should render language picker near generated time and version", () => {
20
+ render(<Footer />);
21
+
22
+ expect(screen.getByTestId("footer-logo")).toBeInTheDocument();
23
+ expect(screen.getByTestId("footer-language-picker")).toBeInTheDocument();
24
+ expect(screen.getByTestId("footer-version")).toHaveTextContent("Generated");
25
+ });
26
+ });
@@ -197,4 +197,52 @@ describe("components > Header > CiInfo", () => {
197
197
 
198
198
  expect(screen.getByRole("link")).toHaveTextContent(fixtures.jobRunName);
199
199
  });
200
+
201
+ it("should not render executor metadata in the header", () => {
202
+ (getReportOptions as Mock).mockReturnValueOnce({
203
+ executor: {
204
+ name: "TeamCity",
205
+ type: "teamcity",
206
+ buildName: "Wrike #123",
207
+ buildUrl: "https://teamcity.example/build/123",
208
+ },
209
+ });
210
+
211
+ render(<CiInfo />);
212
+
213
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
214
+ expect(screen.queryByText("TeamCity · Wrike #123")).not.toBeInTheDocument();
215
+ });
216
+
217
+ it("should render ci label as plain text when ci has a name but no link", () => {
218
+ (getReportOptions as Mock).mockReturnValueOnce({
219
+ ci: {
220
+ jobName: "Nightly Build",
221
+ },
222
+ });
223
+
224
+ render(<CiInfo />);
225
+
226
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
227
+ expect(screen.getByText("Nightly Build")).toBeInTheDocument();
228
+ });
229
+
230
+ it("should ignore executor metadata when ci has no link", () => {
231
+ (getReportOptions as Mock).mockReturnValueOnce({
232
+ ci: {
233
+ jobName: "Nightly Build",
234
+ },
235
+ executor: {
236
+ name: "TeamCity",
237
+ buildName: "Wrike #123",
238
+ buildUrl: "https://teamcity.example/build/123",
239
+ },
240
+ });
241
+
242
+ render(<CiInfo />);
243
+
244
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
245
+ expect(screen.getByText("Nightly Build")).toBeInTheDocument();
246
+ expect(screen.queryByText("TeamCity · Wrike #123")).not.toBeInTheDocument();
247
+ });
200
248
  });