cabloy 5.1.247 → 5.1.249

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 (50) hide show
  1. package/.cabloy-version +1 -1
  2. package/.vscode/settings.json +1 -2
  3. package/CHANGELOG.md +27 -0
  4. package/package.json +1 -7
  5. package/repo-docs/frontend/column-configuration-guide.md +3 -1
  6. package/repo-docs/frontend/table-guide.md +27 -2
  7. package/repo-e2e/specs/a-commerce.spec.ts +32 -0
  8. package/repo-e2e/specs/cabloy-basic.spec.ts +1236 -0
  9. package/vona/package.original.json +1 -7
  10. package/vona/pnpm-lock.yaml +139 -1011
  11. package/zova/package.original.json +0 -6
  12. package/zova/packages-cli/cli/package.json +2 -2
  13. package/zova/packages-cli/cli-set-front/package.json +2 -2
  14. package/zova/packages-utils/zova-vite/package.json +1 -1
  15. package/zova/packages-utils/zova-vite/src/teleports.ts +7 -0
  16. package/zova/packages-utils/zova-vite/test/teleports.test.ts +114 -0
  17. package/zova/packages-zova/zova/package.json +2 -2
  18. package/zova/pnpm-lock.yaml +6 -958
  19. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/controller.tsx +267 -0
  20. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.header.tsx +42 -10
  21. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.locale.tsx +10 -5
  22. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.tabs.tsx +97 -11
  23. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.theme.tsx +19 -10
  24. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.user.tsx +11 -0
  25. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/style.ts +128 -0
  26. package/zova/src/suite/a-home/modules/home-layoutadmin/src/config/locale/en-us.ts +6 -0
  27. package/zova/src/suite/a-home/modules/home-layoutadmin/src/config/locale/zh-cn.ts +6 -0
  28. package/zova/src/suite/a-home/modules/home-layoutweb/src/.metadata/component/layoutWeb.ts +10 -8
  29. package/zova/src/suite/a-home/modules/home-layoutweb/src/.metadata/index.ts +25 -0
  30. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/controller.tsx +83 -0
  31. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.header.tsx +37 -8
  32. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.locale.tsx +25 -8
  33. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.tabs.tsx +3 -1
  34. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.theme.tsx +10 -5
  35. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/render.user.tsx +64 -21
  36. package/zova/src/suite/a-home/modules/home-layoutweb/src/component/layoutWeb/style.ts +42 -0
  37. package/zova/src/suite/a-home/modules/home-layoutweb/src/config/locale/en-us.ts +4 -0
  38. package/zova/src/suite/a-home/modules/home-layoutweb/src/config/locale/zh-cn.ts +4 -0
  39. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/rowOperations/controller.tsx +2 -1
  40. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/table/controller.tsx +6 -10
  41. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/table/render.tsx +9 -6
  42. package/zova/src/suite-vendor/a-zova/modules/a-ssr/package.json +1 -1
  43. package/zova/src/suite-vendor/a-zova/modules/a-ssr/src/lib/ssr.ts +17 -1
  44. package/zova/src/suite-vendor/a-zova/modules/a-ssr/src/lib/ssrProfile.ts +15 -0
  45. package/zova/src/suite-vendor/a-zova/modules/a-ssr/src/types/ssr.ts +1 -0
  46. package/zova/src/suite-vendor/a-zova/modules/a-ssr/test/lib/ssrProfile.test.ts +23 -1
  47. package/zova/src/suite-vendor/a-zova/modules/a-ssrserver/package.json +1 -1
  48. package/zova/src/suite-vendor/a-zova/modules/a-ssrserver/src/service/ssrHandler.ts +11 -11
  49. package/zova/src/suite-vendor/a-zova/package.json +3 -3
  50. package/vona/.textlintrc.json +0 -19
@@ -4,6 +4,10 @@ import { expect, test } from '../scripts/managedTest.ts';
4
4
 
5
5
  const studentResourceUrl =
6
6
  /\/admin\/rest\/resource\/training-student(?:%3A|:|%253A)student(?:[/?#]|$)/;
7
+ const mobileUserAgent =
8
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
9
+ const desktopUserAgent =
10
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
7
11
 
8
12
  interface IModelPersistenceIdentity {
9
13
  appName: string;
@@ -534,6 +538,43 @@ test(
534
538
  },
535
539
  );
536
540
 
541
+ test(
542
+ 'ATP-BASIC-SSR-06: public cache omits compact hints while no-store SSR retains them',
543
+ { tag: ['@web', '@ssr'] },
544
+ async ({ request }) => {
545
+ // The same cacheable URL must remain hint-free for mobile, desktop, then mobile requests.
546
+ for (const userAgent of [mobileUserAgent, desktopUserAgent, mobileUserAgent]) {
547
+ const response = await request.get('/', {
548
+ headers: { 'User-Agent': userAgent },
549
+ });
550
+ expect(response.ok()).toBeTruthy();
551
+ expect(response.headers()['cache-control']).toBe('public, max-age=600');
552
+ expect((response.headers().vary ?? '').toLowerCase().split(/\s*,\s*/)).not.toContain(
553
+ 'user-agent',
554
+ );
555
+ const html = await response.text();
556
+ const state = html.match(/<script[^>]*id="ssr-state-init"[^>]*>([\s\S]*?)<\/script>/)?.[1];
557
+ expect(state).toBeTruthy();
558
+ expect(state).not.toContain('compactHint');
559
+ }
560
+
561
+ for (const compactHint of [true, false]) {
562
+ const response = await request.get('/demo/basic/state', {
563
+ headers: { 'User-Agent': compactHint ? mobileUserAgent : desktopUserAgent },
564
+ });
565
+ expect(response.ok()).toBeTruthy();
566
+ expect(response.headers()['cache-control']).toBe('no-cache, no-store, must-revalidate');
567
+ expect((response.headers().vary ?? '').toLowerCase().split(/\s*,\s*/)).not.toContain(
568
+ 'user-agent',
569
+ );
570
+ const html = await response.text();
571
+ const state = html.match(/<script[^>]*id="ssr-state-init"[^>]*>([\s\S]*?)<\/script>/)?.[1];
572
+ expect(state).toBeTruthy();
573
+ expect(state?.match(/compactHint\s*:\s*(true|false)/)?.[1]).toBe(String(compactHint));
574
+ }
575
+ },
576
+ );
577
+
537
578
  test(
538
579
  'ATP-BASIC-SSR-03: concurrent public routes preserve their cache policies in managed test mode',
539
580
  { tag: ['@web', '@smoke'] },
@@ -675,6 +716,1039 @@ test(
675
716
  },
676
717
  );
677
718
 
719
+ test(
720
+ 'ATP-BASIC-HEADER-01: mobile and desktop SSR header geometry survives hydration',
721
+ { tag: ['@admin', '@ssr'] },
722
+ async ({ page }) => {
723
+ test.setTimeout(120_000);
724
+ await loginAsAdmin(page);
725
+ const pageErrors = collectPageErrors(page);
726
+ const consoleErrors = collectConsoleErrors(page);
727
+ const hydrationWarnings: string[] = [];
728
+ page.on('console', message => {
729
+ if (/hydration.*mismatch|mismatch.*hydration/i.test(message.text())) {
730
+ hydrationWarnings.push(message.text());
731
+ }
732
+ });
733
+ const actions = page.getByTestId('header-actions');
734
+ const panel = actions.locator(':scope > .header-actions-panel');
735
+ const more = actions.locator('button').first();
736
+ const readGeometry = async () => {
737
+ await page.evaluate(() => document.fonts.ready.then(() => undefined));
738
+ return actions.evaluate(async element => {
739
+ await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
740
+ const header = element.closest('.navbar')!;
741
+ const panel = element.querySelector(':scope > .header-actions-panel')!;
742
+ const details = Array.from(panel.querySelectorAll(':scope > ul > li > details'));
743
+ const nodes = [
744
+ header,
745
+ header.nextElementSibling!,
746
+ element,
747
+ element.querySelector('button')!,
748
+ ...details.map(detail => detail.querySelector('summary')!),
749
+ ];
750
+ return {
751
+ // ClientOnly adds menu leaves after hydration, not another action tree.
752
+ shape: {
753
+ wrappers: header.querySelectorAll('[data-testid="header-actions"]').length,
754
+ panels: element.querySelectorAll('.header-actions-panel').length,
755
+ lists: panel.querySelectorAll(':scope > ul').length,
756
+ details: details.length,
757
+ summaries: panel.querySelectorAll(':scope > ul > li > details > summary').length,
758
+ },
759
+ boxes: nodes.map(node => {
760
+ const rect = node.getBoundingClientRect();
761
+ return rect.width && rect.height
762
+ ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
763
+ : null;
764
+ }),
765
+ };
766
+ });
767
+ };
768
+
769
+ for (const width of [390, 1440]) {
770
+ await page.setViewportSize({ width, height: 900 });
771
+ let releaseScripts!: () => void;
772
+ const scriptsHeld = new Promise<void>(resolve => {
773
+ releaseScripts = resolve;
774
+ });
775
+ let heldScripts = 0;
776
+ const holdExternalScripts = async (route: Route) => {
777
+ if (route.request().resourceType() === 'script') {
778
+ heldScripts++;
779
+ await scriptsHeld;
780
+ }
781
+ await route.continue();
782
+ };
783
+ // Leave CSS and the existing inline body-ready/bootstrap scripts untouched.
784
+ await page.route('**/*', holdExternalScripts);
785
+ try {
786
+ const response = await page.goto('/admin/', { waitUntil: 'commit' });
787
+ expect(response?.ok()).toBeTruthy();
788
+ await expect(actions).toHaveCount(1);
789
+ await expect(page.locator('.navbar').nth(1)).toBeVisible();
790
+ await page.waitForFunction(() =>
791
+ Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')).every(
792
+ link => link.sheet !== null,
793
+ ),
794
+ );
795
+ await expect.poll(() => heldScripts).toBeGreaterThan(0);
796
+ await expect(page.locator('html')).not.toHaveAttribute('data-zova-hydrated');
797
+ await expect(page.locator('body')).toBeVisible();
798
+ await expect(more).toHaveAttribute('aria-label', 'More actions');
799
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
800
+ const panelId = await panel.getAttribute('id');
801
+ expect(panelId).toBeTruthy();
802
+ await expect(more).toHaveAttribute('aria-controls', panelId!);
803
+ if (width < 1024) {
804
+ await expect(more).toBeVisible();
805
+ await expect(panel).toBeHidden();
806
+ } else {
807
+ await expect(more).toBeHidden();
808
+ await expect(panel).toBeVisible();
809
+ }
810
+ const labels = panel.locator(':scope > ul > li > details > summary > span');
811
+ await expect(labels).toHaveText(['Language', 'Color mode', 'Theme']);
812
+ const summaries = panel.locator(':scope > ul > li > details > summary');
813
+ for (const [index, title] of ['Language', 'Color mode', 'Theme'].entries()) {
814
+ await expect(summaries.nth(index)).toHaveAttribute('title', title);
815
+ }
816
+ if (width >= 1024) {
817
+ for (const label of await labels.all()) await expect(label).toBeHidden();
818
+ }
819
+ const before = await readGeometry();
820
+ expect(before.shape).toEqual({
821
+ wrappers: 1,
822
+ panels: 1,
823
+ lists: 1,
824
+ details: 4,
825
+ summaries: 4,
826
+ });
827
+ expect(before.boxes[3] !== null).toBe(width < 1024);
828
+ expect(before.boxes.slice(4).every(box => box !== null)).toBe(width >= 1024);
829
+
830
+ releaseScripts();
831
+ await page.waitForLoadState('load');
832
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
833
+ const after = await readGeometry();
834
+ expect(after.shape).toEqual(before.shape);
835
+ await expect(panel).toHaveAttribute('id', panelId!);
836
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
837
+ for (const [index, previous] of before.boxes.entries()) {
838
+ const current = after.boxes[index];
839
+ if (previous === null) {
840
+ expect(current).toBeNull();
841
+ } else {
842
+ expect(current).not.toBeNull();
843
+ for (const key of ['x', 'y', 'width', 'height'] as const) {
844
+ expect(
845
+ Math.abs(current![key] - previous[key]),
846
+ `${width}px header node ${index} ${key}`,
847
+ ).toBeLessThanOrEqual(1);
848
+ }
849
+ }
850
+ }
851
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
852
+ } finally {
853
+ releaseScripts();
854
+ await page.unroute('**/*', holdExternalScripts);
855
+ }
856
+ }
857
+ expect(hydrationWarnings).toEqual([]);
858
+ expect(consoleErrors).toEqual([]);
859
+ expect(pageErrors).toEqual([]);
860
+ },
861
+ );
862
+
863
+ test(
864
+ 'ATP-BASIC-HEADER-02: 320px actions support locale, themes, dismissal and account navigation',
865
+ { tag: ['@admin', '@flow'] },
866
+ async ({ page }) => {
867
+ test.setTimeout(120_000);
868
+ await page.setViewportSize({ width: 320, height: 900 });
869
+ await loginAsAdmin(page);
870
+ const pageErrors = collectPageErrors(page);
871
+ const consoleErrors = collectConsoleErrors(page);
872
+ const actions = page.getByTestId('header-actions');
873
+ const more = actions.locator('button').first();
874
+ const panel = actions.locator(':scope > .header-actions-panel');
875
+ const details = panel.locator(':scope > ul > li > details');
876
+ const locale = details.nth(0);
877
+ const dark = details.nth(1);
878
+ const theme = details.nth(2);
879
+ const user = details.nth(3);
880
+ const openActions = async () => {
881
+ if (!(await panel.isVisible())) await more.click();
882
+ await expect(more).toHaveAttribute('aria-expanded', 'true');
883
+ await expect(panel).toBeVisible();
884
+ };
885
+ const choose = async (menu: Locator, label: string, keyboard = false) => {
886
+ await openActions();
887
+ await menu.locator('summary').click();
888
+ const leaf = menu.getByText(label, { exact: true });
889
+ if (keyboard) await menu.getByRole('button', { name: label, exact: true }).press('Enter');
890
+ else await leaf.click();
891
+ await expect(panel).toBeHidden();
892
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
893
+ await expect(details.locator('ul:visible')).toHaveCount(0);
894
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
895
+ };
896
+ const initialMode = (await dark.locator('li.disabled').textContent())!.trim();
897
+ const initialTheme = (await theme.locator('li.disabled').textContent())!.trim();
898
+ const initialPrimary = await page
899
+ .locator('body')
900
+ .evaluate(element => element.style.getPropertyValue('--color-primary'));
901
+ const secondNavbar = page.locator('.navbar').nth(1);
902
+ const before = await secondNavbar.boundingBox();
903
+ await expect(more).toHaveAccessibleName('More actions');
904
+ await expect(panel).toBeHidden();
905
+ await openActions();
906
+ await expect(details).toHaveCount(4);
907
+ const labels = details.locator('summary > span');
908
+ await expect(labels).toHaveText(['Language', 'Color mode', 'Theme']);
909
+ for (const label of await labels.all()) await expect(label).toBeVisible();
910
+ await expect(panel).toHaveCSS('position', 'absolute');
911
+ await expect(panel).toHaveCSS('width', '256px');
912
+ const panelBox = await panel.boundingBox();
913
+ expect(panelBox!.x).toBeGreaterThanOrEqual(0);
914
+ expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(320);
915
+ const summaries = await details.locator('summary').evaluateAll(elements =>
916
+ elements.map(element => {
917
+ const rect = element.getBoundingClientRect();
918
+ return { x: rect.x, top: rect.top, bottom: rect.bottom };
919
+ }),
920
+ );
921
+ for (let index = 1; index < summaries.length; index++) {
922
+ expect(summaries[index].top).toBeGreaterThanOrEqual(summaries[index - 1].bottom - 1);
923
+ expect(Math.abs(summaries[index].x - summaries[0].x)).toBeLessThanOrEqual(1);
924
+ }
925
+ await locale.locator('summary').click();
926
+ await expect(locale.locator('ul')).not.toHaveCSS('position', 'absolute');
927
+ await expect
928
+ .poll(async () => {
929
+ const nested = await locale.locator('ul').boundingBox();
930
+ const nextSummary = await dark.locator('summary').boundingBox();
931
+ return nextSummary!.y - nested!.y - nested!.height;
932
+ })
933
+ .toBeGreaterThanOrEqual(-1);
934
+ expect(await secondNavbar.boundingBox()).toEqual(before);
935
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
936
+ await locale.locator('summary').press('Escape');
937
+ await expect(locale).not.toHaveAttribute('open');
938
+ await expect(panel).toBeVisible();
939
+ await expect(locale.locator('summary')).toBeFocused();
940
+ await page.keyboard.press('Escape');
941
+ await expect(panel).toBeHidden();
942
+ await expect(more).toBeFocused();
943
+
944
+ await openActions();
945
+ await page
946
+ .locator('.navbar')
947
+ .first()
948
+ .click({ position: { x: 1, y: 1 } });
949
+ await expect(panel).toBeHidden();
950
+ await openActions();
951
+ await page.locator('.navbar').first().locator('button').first().focus();
952
+ await expect(panel).toBeHidden();
953
+
954
+ await choose(locale, 'Chinese', true);
955
+ await expect(more).toHaveAccessibleName('更多操作');
956
+ await openActions();
957
+ await expect(labels).toHaveText(['语言', '明暗模式', '主题']);
958
+ for (const label of await labels.all()) await expect(label).toBeVisible();
959
+ await choose(locale, '英语');
960
+ await expect(more).toHaveAccessibleName('More actions');
961
+ for (const mode of ['Dark', 'Light']) {
962
+ await choose(dark, mode);
963
+ await expect(dark.locator('li.disabled')).toHaveText(mode);
964
+ await expect(page.locator('body')).toHaveAttribute('data-theme', mode.toLowerCase());
965
+ }
966
+ if (initialMode !== 'Light') await choose(dark, initialMode);
967
+ const alternateTheme = initialTheme === 'Blue' ? 'Orange' : 'Blue';
968
+ await choose(theme, alternateTheme);
969
+ await expect(theme.locator('li.disabled')).toHaveText(alternateTheme);
970
+ await expect
971
+ .poll(() =>
972
+ page.locator('body').evaluate(element => element.style.getPropertyValue('--color-primary')),
973
+ )
974
+ .not.toBe(initialPrimary);
975
+ await choose(theme, initialTheme);
976
+ await expect(theme.locator('li.disabled')).toHaveText(initialTheme);
977
+ await expect(dark.locator('li.disabled')).toHaveText(initialMode);
978
+ await expect
979
+ .poll(() =>
980
+ page.locator('body').evaluate(element => element.style.getPropertyValue('--color-primary')),
981
+ )
982
+ .toBe(initialPrimary);
983
+
984
+ await choose(user, 'Account Settings');
985
+ await expect(page).toHaveURL(/\/admin\/home\/user\/account(?:[/?#]|$)/);
986
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
987
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
988
+ expect(consoleErrors).toEqual([]);
989
+ expect(pageErrors).toEqual([]);
990
+ },
991
+ );
992
+
993
+ test(
994
+ 'ATP-BASIC-HEADER-03: the lg breakpoint reuses one action tree and clears transient menus',
995
+ { tag: ['@admin', '@flow'] },
996
+ async ({ page }) => {
997
+ await page.setViewportSize({ width: 1023, height: 900 });
998
+ await loginAsAdmin(page);
999
+ const pageErrors = collectPageErrors(page);
1000
+ const actions = page.getByTestId('header-actions');
1001
+ const more = actions.locator('button').first();
1002
+ const panel = actions.locator(':scope > .header-actions-panel');
1003
+ const details = panel.locator(':scope > ul > li > details');
1004
+ await expect(more).toBeVisible();
1005
+ await expect(panel).toBeHidden();
1006
+ const originalPanel = await panel.elementHandle();
1007
+ expect(originalPanel).not.toBeNull();
1008
+ try {
1009
+ await more.click();
1010
+ await details.first().locator('summary').click();
1011
+ await expect(details.first()).toHaveAttribute('open', '');
1012
+ await page.setViewportSize({ width: 1024, height: 900 });
1013
+ await expect(more).toBeHidden();
1014
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
1015
+ await expect(panel).toBeVisible();
1016
+ await expect(panel).toHaveCSS('position', 'static');
1017
+ await expect(panel.locator('details[open]')).toHaveCount(0);
1018
+ expect(await panel.evaluate((element, original) => element === original, originalPanel)).toBe(
1019
+ true,
1020
+ );
1021
+ await expect(details.locator('summary')).toHaveCount(4);
1022
+ const desktop = await details.locator('summary').evaluateAll(elements =>
1023
+ elements.map(element => {
1024
+ const rect = element.getBoundingClientRect();
1025
+ return { x: rect.x, y: rect.y };
1026
+ }),
1027
+ );
1028
+ for (let index = 1; index < desktop.length; index++) {
1029
+ expect(Math.abs(desktop[index].y - desktop[0].y)).toBeLessThanOrEqual(1);
1030
+ expect(desktop[index].x).toBeGreaterThan(desktop[index - 1].x);
1031
+ }
1032
+ await details.last().locator('summary').click();
1033
+ await expect(details.last()).toHaveAttribute('open', '');
1034
+ await page.setViewportSize({ width: 1023, height: 900 });
1035
+ await expect(more).toBeVisible();
1036
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
1037
+ await expect(panel).toBeHidden();
1038
+ await expect(panel.locator('details[open]')).toHaveCount(0);
1039
+ expect(await panel.evaluate((element, original) => element === original, originalPanel)).toBe(
1040
+ true,
1041
+ );
1042
+ await expect(actions).toHaveCount(1);
1043
+ await expect(panel).toHaveCount(1);
1044
+ await expect(details).toHaveCount(4);
1045
+ await more.click();
1046
+ await expect(panel).toBeVisible();
1047
+ await page.setViewportSize({ width: 390, height: 900 });
1048
+ await expect(panel).toBeHidden();
1049
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
1050
+ expect(await panel.evaluate((element, original) => element === original, originalPanel)).toBe(
1051
+ true,
1052
+ );
1053
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
1054
+ expect(pageErrors).toEqual([]);
1055
+ } finally {
1056
+ await originalPanel?.dispose();
1057
+ }
1058
+ },
1059
+ );
1060
+
1061
+ test(
1062
+ 'ATP-BASIC-WEB-HEADER-01: public mobile and desktop header geometry survives anonymous and signed-in hydration',
1063
+ { tag: ['@web', '@ssr'] },
1064
+ async ({ page }) => {
1065
+ test.setTimeout(180_000);
1066
+ const pageErrors = collectPageErrors(page);
1067
+ const consoleErrors = collectConsoleErrors(page);
1068
+ const hydrationWarnings: string[] = [];
1069
+ page.on('console', message => {
1070
+ if (/hydration.*mismatch|mismatch.*hydration/i.test(message.text())) {
1071
+ hydrationWarnings.push(message.text());
1072
+ }
1073
+ });
1074
+ const actions = page.getByTestId('header-actions');
1075
+ const panel = actions.locator(':scope > .header-actions-panel');
1076
+ const more = actions.locator('button').first();
1077
+ const details = panel.locator(':scope > ul > li > details');
1078
+ const account = details.nth(2);
1079
+ const readGeometry = async () => {
1080
+ await page.evaluate(() => document.fonts.ready.then(() => undefined));
1081
+ return actions.evaluate(async element => {
1082
+ await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
1083
+ const header = element.closest('.navbar')!;
1084
+ const panel = element.querySelector(':scope > .header-actions-panel')!;
1085
+ const details = Array.from(panel.querySelectorAll(':scope > ul > li > details'));
1086
+ const account = details[2].querySelector('summary')!;
1087
+ const nodes = [
1088
+ header,
1089
+ element.previousElementSibling!,
1090
+ element,
1091
+ element.querySelector('button')!,
1092
+ ...details.map(detail => detail.querySelector('summary')!),
1093
+ account.querySelector('img')!,
1094
+ account.querySelector('span')!,
1095
+ ];
1096
+ return {
1097
+ shape: {
1098
+ wrappers: header.querySelectorAll('[data-testid="header-actions"]').length,
1099
+ panels: element.querySelectorAll('.header-actions-panel').length,
1100
+ lists: panel.querySelectorAll(':scope > ul').length,
1101
+ details: details.length,
1102
+ summaries: panel.querySelectorAll(':scope > ul > li > details > summary').length,
1103
+ },
1104
+ boxes: nodes.map(node => {
1105
+ const rect = node.getBoundingClientRect();
1106
+ return rect.width && rect.height
1107
+ ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
1108
+ : null;
1109
+ }),
1110
+ };
1111
+ });
1112
+ };
1113
+
1114
+ let displayName = '';
1115
+ for (const authenticated of [false, true]) {
1116
+ if (authenticated) {
1117
+ await loginAsAdmin(page);
1118
+ displayName = (await page
1119
+ .getByTestId('header-actions')
1120
+ .locator('details')
1121
+ .last()
1122
+ .locator('summary')
1123
+ .textContent())!.trim();
1124
+ expect(displayName).not.toBe('');
1125
+ }
1126
+ for (const width of [390, 1440]) {
1127
+ await page.setViewportSize({ width, height: 900 });
1128
+ let releaseScripts!: () => void;
1129
+ const scriptsHeld = new Promise<void>(resolve => {
1130
+ releaseScripts = resolve;
1131
+ });
1132
+ let heldScripts = 0;
1133
+ const holdExternalScripts = async (route: Route) => {
1134
+ if (route.request().resourceType() === 'script') {
1135
+ heldScripts++;
1136
+ await scriptsHeld;
1137
+ }
1138
+ await route.continue();
1139
+ };
1140
+ // Keep CSS and inline bootstrap scripts running while inspecting the public SSR shell.
1141
+ await page.route('**/*', holdExternalScripts);
1142
+ try {
1143
+ const response = await page.goto('/', { waitUntil: 'commit' });
1144
+ expect(response?.ok()).toBeTruthy();
1145
+ expect(response?.headers()['cache-control']).toBe('public, max-age=600');
1146
+ await expect(actions).toHaveCount(1);
1147
+ await page.waitForFunction(() =>
1148
+ Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')).every(
1149
+ link => link.sheet !== null,
1150
+ ),
1151
+ );
1152
+ await expect.poll(() => heldScripts).toBeGreaterThan(0);
1153
+ await expect(page.locator('html')).not.toHaveAttribute('data-zova-hydrated');
1154
+ await expect(page.locator('body')).toBeVisible();
1155
+ await expect(panel).toHaveAttribute('id', 'home-layoutweb-header-actions');
1156
+ await expect(more).toHaveAttribute('aria-controls', 'home-layoutweb-header-actions');
1157
+ await expect(more).toHaveAttribute('aria-label', 'More actions');
1158
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
1159
+ await expect(details).toHaveCount(3);
1160
+ const summaries = details.locator('summary');
1161
+ for (const [index, title] of ['Language', 'Color mode', 'Account'].entries()) {
1162
+ await expect(summaries.nth(index)).toHaveAttribute('title', title);
1163
+ }
1164
+ await expect(account.locator('summary')).toHaveAttribute('aria-label', 'Account');
1165
+ await expect(account.locator('summary')).toHaveText('');
1166
+ await expect(details.locator(':scope > ul')).toHaveCount(0);
1167
+ const labels = details.locator('summary > span.lg\\:hidden');
1168
+ await expect(labels).toHaveText(['Language', 'Color mode']);
1169
+ if (width < 1024) {
1170
+ await expect(more).toBeVisible();
1171
+ await expect(panel).toBeHidden();
1172
+ } else {
1173
+ await expect(more).toBeHidden();
1174
+ await expect(panel).toBeVisible();
1175
+ for (const label of await labels.all()) await expect(label).toBeHidden();
1176
+ await expect(account.locator('summary > img')).toHaveCSS('width', '24px');
1177
+ await expect(account.locator('summary > img')).toHaveCSS('height', '24px');
1178
+ await expect(account.locator('summary > span')).toHaveCSS('width', '128px');
1179
+ }
1180
+ const before = await readGeometry();
1181
+ expect(before.shape).toEqual({
1182
+ wrappers: 1,
1183
+ panels: 1,
1184
+ lists: 1,
1185
+ details: 3,
1186
+ summaries: 3,
1187
+ });
1188
+ expect(before.boxes[3] !== null).toBe(width < 1024);
1189
+ expect(before.boxes.slice(4).every(box => box !== null)).toBe(width >= 1024);
1190
+
1191
+ releaseScripts();
1192
+ await page.waitForLoadState('load');
1193
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web');
1194
+ await expect(account.locator('summary')).toHaveText(
1195
+ authenticated ? displayName : 'Not logged in',
1196
+ );
1197
+ await expect(account.locator('summary')).toHaveAttribute('aria-label', 'Account');
1198
+ await expect(account.locator('summary')).toHaveAttribute('title', 'Account');
1199
+ await expect(account.getByText('Login', { exact: true })).toHaveCount(
1200
+ authenticated ? 0 : 1,
1201
+ );
1202
+ await expect(
1203
+ account.getByRole('link', {
1204
+ name: 'Account Settings',
1205
+ exact: true,
1206
+ includeHidden: true,
1207
+ }),
1208
+ ).toHaveCount(authenticated ? 1 : 0);
1209
+ await expect(
1210
+ account.getByRole('button', { name: 'Logout', exact: true, includeHidden: true }),
1211
+ ).toHaveCount(authenticated ? 1 : 0);
1212
+ const after = await readGeometry();
1213
+ expect(after.shape).toEqual(before.shape);
1214
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
1215
+ for (const [index, previous] of before.boxes.entries()) {
1216
+ const current = after.boxes[index];
1217
+ if (previous === null) {
1218
+ expect(current).toBeNull();
1219
+ } else {
1220
+ expect(current).not.toBeNull();
1221
+ for (const key of ['x', 'y', 'width', 'height'] as const) {
1222
+ expect(
1223
+ Math.abs(current![key] - previous[key]),
1224
+ `${authenticated ? 'signed-in' : 'anonymous'} ${width}px header node ${index} ${key}`,
1225
+ ).toBeLessThanOrEqual(1);
1226
+ }
1227
+ }
1228
+ }
1229
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
1230
+ } finally {
1231
+ releaseScripts();
1232
+ await page.unroute('**/*', holdExternalScripts);
1233
+ }
1234
+ }
1235
+ }
1236
+ await account.locator('summary').click();
1237
+ const accountLink = account.getByRole('link', { name: 'Account Settings', exact: true });
1238
+ await expect(accountLink).toHaveAttribute('href', '/home/user/account');
1239
+ await accountLink.click();
1240
+ await expect(page).toHaveURL(/\/home\/user\/account(?:[/?#]|$)/);
1241
+ await expect(
1242
+ page.getByRole('heading', { name: 'Account Settings', exact: true }),
1243
+ ).toBeVisible();
1244
+ await expect(account).not.toHaveAttribute('open');
1245
+ expect(hydrationWarnings).toEqual([]);
1246
+ expect(consoleErrors).toEqual([]);
1247
+ expect(pageErrors).toEqual([]);
1248
+ },
1249
+ );
1250
+
1251
+ test(
1252
+ 'ATP-BASIC-WEB-HEADER-02: 320px actions preserve locale navigation and support color mode, dismissal and Login',
1253
+ { tag: ['@web', '@flow'] },
1254
+ async ({ page }) => {
1255
+ test.setTimeout(120_000);
1256
+ await page.setViewportSize({ width: 320, height: 900 });
1257
+ const pageErrors = collectPageErrors(page);
1258
+ const consoleErrors = collectConsoleErrors(page);
1259
+ await page.goto('/en-us?headerProbe=one&headerProbe=two', { waitUntil: 'load' });
1260
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web');
1261
+ const actions = page.getByTestId('header-actions');
1262
+ const more = actions.locator('button').first();
1263
+ const panel = actions.locator(':scope > .header-actions-panel');
1264
+ const details = panel.locator(':scope > ul > li > details');
1265
+ const locale = details.nth(0);
1266
+ const dark = details.nth(1);
1267
+ const account = details.nth(2);
1268
+ const labels = details.locator('summary > span.lg\\:hidden');
1269
+ const header = page.locator('.navbar').first();
1270
+ const before = await header.boundingBox();
1271
+ const openActions = async () => {
1272
+ if (!(await panel.isVisible())) await more.click();
1273
+ await expect(more).toHaveAttribute('aria-expanded', 'true');
1274
+ await expect(panel).toBeVisible();
1275
+ };
1276
+ const choose = async (menu: Locator, label: string, keyboard = false) => {
1277
+ await openActions();
1278
+ await menu.locator('summary').click();
1279
+ const leaf = menu.getByText(label, { exact: true });
1280
+ if (keyboard) await menu.getByRole('button', { name: label, exact: true }).press('Enter');
1281
+ else await leaf.click();
1282
+ await expect(panel).toBeHidden();
1283
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
1284
+ await expect(details.locator('ul:visible')).toHaveCount(0);
1285
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
1286
+ };
1287
+ const initialMode = (await dark.locator('li.disabled').textContent())!.trim();
1288
+ await expect(more).toHaveAccessibleName('More actions');
1289
+ await expect(panel).toBeHidden();
1290
+ await openActions();
1291
+ await expect(details).toHaveCount(3);
1292
+ await expect(labels).toHaveText(['Language', 'Color mode']);
1293
+ for (const label of await labels.all()) await expect(label).toBeVisible();
1294
+ await expect(panel).toHaveCSS('position', 'absolute');
1295
+ await expect(panel).toHaveCSS('width', '256px');
1296
+ const panelBox = await panel.boundingBox();
1297
+ expect(panelBox!.x).toBeGreaterThanOrEqual(0);
1298
+ expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(320);
1299
+ const summaries = await details.locator('summary').evaluateAll(elements =>
1300
+ elements.map(element => {
1301
+ const rect = element.getBoundingClientRect();
1302
+ return { x: rect.x, top: rect.top, bottom: rect.bottom };
1303
+ }),
1304
+ );
1305
+ for (let index = 1; index < summaries.length; index++) {
1306
+ expect(summaries[index].top).toBeGreaterThanOrEqual(summaries[index - 1].bottom - 1);
1307
+ expect(Math.abs(summaries[index].x - summaries[0].x)).toBeLessThanOrEqual(1);
1308
+ }
1309
+ await locale.locator('summary').click();
1310
+ await expect(locale.locator('ul')).not.toHaveCSS('position', 'absolute');
1311
+ await expect
1312
+ .poll(async () => {
1313
+ const nested = await locale.locator('ul').boundingBox();
1314
+ const nextSummary = await dark.locator('summary').boundingBox();
1315
+ return nextSummary!.y - nested!.y - nested!.height;
1316
+ })
1317
+ .toBeGreaterThanOrEqual(-1);
1318
+ expect(await header.boundingBox()).toEqual(before);
1319
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
1320
+ await locale.locator('summary').press('Escape');
1321
+ await expect(locale).not.toHaveAttribute('open');
1322
+ await expect(panel).toBeVisible();
1323
+ await expect(locale.locator('summary')).toBeFocused();
1324
+ await page.keyboard.press('Escape');
1325
+ await expect(panel).toBeHidden();
1326
+ await expect(more).toBeFocused();
1327
+ await openActions();
1328
+ await header.click({ position: { x: 1, y: 1 } });
1329
+ await expect(panel).toBeHidden();
1330
+ await openActions();
1331
+ await header.locator('button').first().focus();
1332
+ await expect(panel).toBeHidden();
1333
+
1334
+ await choose(locale, 'Chinese', true);
1335
+ await expect(page).toHaveURL(url => url.pathname === '/zh-cn');
1336
+ // $gotoPage serializes array-valued query entries as JSON.
1337
+ expect(JSON.parse(new URL(page.url()).searchParams.get('headerProbe')!)).toEqual([
1338
+ 'one',
1339
+ 'two',
1340
+ ]);
1341
+ await expect(more).toHaveAccessibleName('更多操作');
1342
+ await openActions();
1343
+ await expect(labels).toHaveText(['语言', '明暗模式']);
1344
+ for (const label of await labels.all()) await expect(label).toBeVisible();
1345
+ await choose(locale, '英语');
1346
+ await expect(page).toHaveURL(url => url.pathname === '/');
1347
+ expect(JSON.parse(new URL(page.url()).searchParams.get('headerProbe')!)).toEqual([
1348
+ 'one',
1349
+ 'two',
1350
+ ]);
1351
+ await expect(more).toHaveAccessibleName('More actions');
1352
+ for (const mode of ['Dark', 'Light']) {
1353
+ await choose(dark, mode);
1354
+ await expect(dark.locator('li.disabled')).toHaveText(mode);
1355
+ await expect(page.locator('body')).toHaveAttribute('data-theme', mode.toLowerCase());
1356
+ }
1357
+ if (initialMode !== 'Light') await choose(dark, initialMode);
1358
+ await expect(dark.locator('li.disabled')).toHaveText(initialMode);
1359
+ await openActions();
1360
+ await account.locator('summary').click();
1361
+ await account.getByText('Login', { exact: true }).click();
1362
+ await expect(page).toHaveURL(/\/login(?:[/?#]|$)/);
1363
+ await expect(page.getByRole('heading', { name: 'Login', exact: true })).toBeVisible();
1364
+ expect(consoleErrors).toEqual([]);
1365
+ expect(pageErrors).toEqual([]);
1366
+ },
1367
+ );
1368
+
1369
+ test(
1370
+ 'ATP-BASIC-WEB-HEADER-03: the lg breakpoint reuses one Web action tree and clears transient menus',
1371
+ { tag: ['@web', '@flow'] },
1372
+ async ({ page }) => {
1373
+ await page.setViewportSize({ width: 1023, height: 900 });
1374
+ const pageErrors = collectPageErrors(page);
1375
+ await page.goto('/', { waitUntil: 'load' });
1376
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web');
1377
+ const actions = page.getByTestId('header-actions');
1378
+ const more = actions.locator('button').first();
1379
+ const panel = actions.locator(':scope > .header-actions-panel');
1380
+ const details = panel.locator(':scope > ul > li > details');
1381
+ const labels = details.locator('summary > span.lg\\:hidden');
1382
+ await expect(more).toBeVisible();
1383
+ await expect(panel).toBeHidden();
1384
+ const originalPanel = await panel.elementHandle();
1385
+ expect(originalPanel).not.toBeNull();
1386
+ try {
1387
+ await more.click();
1388
+ for (const label of await labels.all()) await expect(label).toBeVisible();
1389
+ await details.first().locator('summary').click();
1390
+ await expect(details.first()).toHaveAttribute('open', '');
1391
+ await page.setViewportSize({ width: 1024, height: 900 });
1392
+ await expect(more).toBeHidden();
1393
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
1394
+ await expect(panel).toBeVisible();
1395
+ await expect(panel).toHaveCSS('position', 'static');
1396
+ await expect(panel.locator('details[open]')).toHaveCount(0);
1397
+ expect(await panel.evaluate((element, original) => element === original, originalPanel)).toBe(
1398
+ true,
1399
+ );
1400
+ for (const label of await labels.all()) await expect(label).toBeHidden();
1401
+ await expect(details.locator('summary')).toHaveCount(3);
1402
+ const desktop = await details.locator('summary').evaluateAll(elements =>
1403
+ elements.map(element => {
1404
+ const rect = element.getBoundingClientRect();
1405
+ return { x: rect.x, y: rect.y };
1406
+ }),
1407
+ );
1408
+ for (let index = 1; index < desktop.length; index++) {
1409
+ expect(Math.abs(desktop[index].y - desktop[0].y)).toBeLessThanOrEqual(1);
1410
+ expect(desktop[index].x).toBeGreaterThan(desktop[index - 1].x);
1411
+ }
1412
+ await details.last().locator('summary').click();
1413
+ await expect(details.last()).toHaveAttribute('open', '');
1414
+ await page.setViewportSize({ width: 1023, height: 900 });
1415
+ await expect(more).toBeVisible();
1416
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
1417
+ await expect(panel).toBeHidden();
1418
+ await expect(panel.locator('details[open]')).toHaveCount(0);
1419
+ expect(await panel.evaluate((element, original) => element === original, originalPanel)).toBe(
1420
+ true,
1421
+ );
1422
+ await expect(actions).toHaveCount(1);
1423
+ await expect(panel).toHaveCount(1);
1424
+ await expect(details).toHaveCount(3);
1425
+ await more.click();
1426
+ await expect(panel).toBeVisible();
1427
+ await page.setViewportSize({ width: 390, height: 900 });
1428
+ await expect(panel).toBeHidden();
1429
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
1430
+ expect(await panel.evaluate((element, original) => element === original, originalPanel)).toBe(
1431
+ true,
1432
+ );
1433
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
1434
+ expect(pageErrors).toEqual([]);
1435
+ } finally {
1436
+ await originalPanel?.dispose();
1437
+ }
1438
+ },
1439
+ );
1440
+
1441
+ test(
1442
+ 'ATP-BASIC-TABS-01: task tabs reveal active drafts, compact by container width and switch or close through All tabs',
1443
+ { tag: ['@admin', '@flow', '@layout'] },
1444
+ async ({ page }, testInfo) => {
1445
+ test.setTimeout(120_000);
1446
+ await page.setViewportSize({ width: 1440, height: 900 });
1447
+ await loginAsAdmin(page);
1448
+ await withDraftStudents(page, 3, async students => {
1449
+ const effects = collectDraftEffects(page, true);
1450
+ // The existing three-task limit excludes the workspace anchor; do not raise it.
1451
+ const titles = students.map(
1452
+ (student, index) =>
1453
+ `${student.name} task ${index + 1} with a deliberately long unique unsaved tab title`,
1454
+ );
1455
+ for (const [index, student] of students.entries()) {
1456
+ await openStudentDraftEdit(page, student.name, student.id);
1457
+ await studentDraftNameInput(page).fill(titles[index]!);
1458
+ await waitForStudentDraft(page, titles[index]!, student.id);
1459
+ }
1460
+
1461
+ const items = page.getByTestId('tab-items');
1462
+ const more = page.getByTestId('tab-items-more');
1463
+ // A CSS/test-id locator also resolves the native popover while it is closed.
1464
+ const list = page.getByTestId('tab-items-list');
1465
+ const tabs = items.getByRole('tab');
1466
+ const viewport = items.locator('.tab-items-viewport');
1467
+ const scroller = viewport.locator(':scope > .tab-items-scroll');
1468
+ const task = (index: number) => items.getByRole('tab', { name: titles[index], exact: true });
1469
+ const select = (index: number) =>
1470
+ list.locator('.tab-items-select').filter({ hasText: titles[index]! });
1471
+ const expectBounds = async (popup: Locator) => {
1472
+ const bounds = (await popup.boundingBox())!;
1473
+ expect(bounds).not.toBeNull();
1474
+ expect(bounds.x).toBeGreaterThanOrEqual(-1);
1475
+ expect(bounds.y).toBeGreaterThanOrEqual(-1);
1476
+ expect(bounds.x + bounds.width).toBeLessThanOrEqual(page.viewportSize()!.width + 1);
1477
+ expect(bounds.y + bounds.height).toBeLessThanOrEqual(page.viewportSize()!.height + 1);
1478
+ };
1479
+ const openMenu = async (remaining: number[], active?: number, keyboard = false) => {
1480
+ await expect(more).toHaveAccessibleName('All tabs');
1481
+ await expect(more).toHaveJSProperty('tagName', 'BUTTON');
1482
+ await expect(list).toBeHidden();
1483
+ if (keyboard) {
1484
+ await more.focus();
1485
+ await more.press('Enter');
1486
+ } else {
1487
+ await more.click();
1488
+ }
1489
+ await expect(list).toBeVisible();
1490
+ await expect(list).toHaveAttribute('popover', 'auto');
1491
+ await expect(list).toHaveAttribute('role', 'group');
1492
+ await expect(list).toHaveAccessibleName('All tabs');
1493
+ expect(await list.evaluate(element => element.matches(':popover-open'))).toBe(true);
1494
+ await expect(list.locator('.tab-items-row')).toHaveCount(remaining.length);
1495
+ await expect(list.locator('.tab-items-select')).toHaveCount(remaining.length);
1496
+ await expect(list.locator('.tab-items-select').first()).toBeFocused();
1497
+ for (const index of remaining) {
1498
+ const row = list.locator('.tab-items-row').filter({
1499
+ has: page.locator('.tab-items-select').filter({ hasText: titles[index]! }),
1500
+ });
1501
+ await expect(row).toHaveCount(1);
1502
+ await expect(select(index)).toHaveJSProperty('tagName', 'BUTTON');
1503
+ const close = row.getByRole('button', {
1504
+ name: `Close tab: ${titles[index]}`,
1505
+ exact: true,
1506
+ });
1507
+ await expect(close).toBeEnabled();
1508
+ // Switching and closing are sibling actions, never nested buttons.
1509
+ await expect(select(index).locator('button')).toHaveCount(0);
1510
+ await expect(row.getByRole('button')).toHaveCount(2);
1511
+ if (index === active) await expect(select(index)).toHaveAttribute('aria-current', 'page');
1512
+ else await expect(select(index)).not.toHaveAttribute('aria-current', 'page');
1513
+ expect(
1514
+ await select(index).evaluate(element => {
1515
+ const rect = element.getBoundingClientRect();
1516
+ return element.contains(
1517
+ document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2),
1518
+ );
1519
+ }),
1520
+ ).toBe(true);
1521
+ }
1522
+ await expect(list.locator('.tab-items-select[aria-current="page"]')).toHaveCount(
1523
+ active === undefined ? 0 : 1,
1524
+ );
1525
+ await expectBounds(list);
1526
+ };
1527
+ const choose = async (index: number, keyboard = false) => {
1528
+ if (keyboard) {
1529
+ await select(index).focus();
1530
+ await select(index).press('Enter');
1531
+ } else {
1532
+ await select(index).click();
1533
+ }
1534
+ await expect(list).toBeHidden();
1535
+ await expect(page).toHaveURL(
1536
+ new RegExp(`/training-student(?:%3A|:)student/${students[index]!.id}/edit(?:[?#]|$)`),
1537
+ );
1538
+ await expect(studentDraftNameInput(page)).toHaveValue(titles[index]!);
1539
+ await expect(task(index)).toHaveAttribute('aria-selected', 'true');
1540
+ };
1541
+ const expectStrip = async (compact: boolean, count: number) => {
1542
+ await expect(items).toHaveAttribute('data-compact', String(compact));
1543
+ const width = await items.evaluate(element => element.getBoundingClientRect().width);
1544
+ if (compact) expect(width).toBeLessThan(240);
1545
+ else expect(width).toBeGreaterThanOrEqual(240);
1546
+ await expect(tabs).toHaveCount(count);
1547
+ await expect(items.locator('.tab-items-content')).toHaveAttribute('role', 'tablist');
1548
+ await expect(more).toBeVisible();
1549
+ await expect
1550
+ .poll(() =>
1551
+ items.evaluate(element => {
1552
+ const header = element.closest('.navbar')!;
1553
+ const headerBox = header.getBoundingClientRect();
1554
+ const more = header.querySelector('[data-testid="tab-items-more"]')!;
1555
+ const boxes = [element, more].map(node => node.getBoundingClientRect());
1556
+ return (
1557
+ headerBox.left >= -1 &&
1558
+ headerBox.right <= document.documentElement.clientWidth + 1 &&
1559
+ header.scrollWidth <= header.clientWidth + 1 &&
1560
+ boxes.every(
1561
+ box =>
1562
+ box.width > 0 &&
1563
+ box.left >= headerBox.left - 1 &&
1564
+ box.right <= headerBox.right + 1,
1565
+ )
1566
+ );
1567
+ }),
1568
+ )
1569
+ .toBe(true);
1570
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
1571
+ };
1572
+ const expectActiveRevealed = async (index: number) => {
1573
+ await expect
1574
+ .poll(() =>
1575
+ task(index).evaluate(element => {
1576
+ const viewport = element.closest('.tab-items-scroll')!;
1577
+ const tab = element.getBoundingClientRect();
1578
+ const clip = viewport.getBoundingClientRect();
1579
+ return tab.left >= clip.left - 1 && tab.right <= clip.right + 1;
1580
+ }),
1581
+ )
1582
+ .toBe(true);
1583
+ };
1584
+ const close = async (index: number) => {
1585
+ await list
1586
+ .getByRole('button', { name: `Close tab: ${titles[index]}`, exact: true })
1587
+ .click();
1588
+ // Assert the exact persisted identity, not just removal of a rendered row.
1589
+ await expectNoStudentDraft(page, students[index]!.id);
1590
+ await expect(task(index)).toHaveCount(0);
1591
+ await expect(select(index)).toHaveCount(0);
1592
+ // A close action may intentionally leave the list open for consecutive closes.
1593
+ if (await list.isVisible()) await page.keyboard.press('Escape');
1594
+ await expect(list).toBeHidden();
1595
+ };
1596
+
1597
+ await expectStrip(false, 3);
1598
+ await expect(task(2)).toHaveAttribute('aria-selected', 'true');
1599
+ await expectActiveRevealed(2);
1600
+ await task(2).hover();
1601
+ const taskClose = task(2).locator('.tab-close');
1602
+ await expect(taskClose).toBeVisible();
1603
+ await expect(taskClose).toHaveCSS('top', '2px');
1604
+ expect(
1605
+ await taskClose.evaluate(element => {
1606
+ const icon = element.getBoundingClientRect();
1607
+ const tab = element.closest('[role="tab"]')!.getBoundingClientRect();
1608
+ const clip = element.closest('.tab-items-scroll')!.getBoundingClientRect();
1609
+ return (
1610
+ icon.top >= Math.max(tab.top, clip.top) &&
1611
+ icon.bottom <= Math.min(tab.bottom, clip.bottom) &&
1612
+ icon.left >= Math.max(tab.left, clip.left) &&
1613
+ icon.right <= Math.min(tab.right, clip.right)
1614
+ );
1615
+ }),
1616
+ ).toBe(true);
1617
+ await expect(list).toBeHidden();
1618
+ await expect(scroller).toHaveJSProperty('tagName', 'DIV');
1619
+ await expect(scroller).toHaveCSS('overflow-x', 'auto');
1620
+
1621
+ // Basic caps tab titles at 130px. Find a naturally constrained header where
1622
+ // all three tasks still render (wrapper >=240px) and actually overflow.
1623
+ const geometries: { width: number; wrapper: number; overflow: number }[] = [];
1624
+ for (const width of [900, 800, 700, 600, 520, 480, 440, 400]) {
1625
+ await page.setViewportSize({ width, height: 900 });
1626
+ const geometry = await items.evaluate(async element => {
1627
+ // Let the container observer and its render settle before choosing a size.
1628
+ await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
1629
+ await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
1630
+ const scroller = element.querySelector('.tab-items-scroll')!;
1631
+ return {
1632
+ wrapper: element.getBoundingClientRect().width,
1633
+ overflow: scroller.scrollWidth - scroller.clientWidth,
1634
+ };
1635
+ });
1636
+ geometries.push({ width, ...geometry });
1637
+ if (geometry.wrapper >= 240 && geometry.overflow > 1) break;
1638
+ if (geometry.wrapper < 240) break;
1639
+ }
1640
+ expect(
1641
+ geometries.some(geometry => geometry.wrapper >= 240 && geometry.overflow > 1),
1642
+ `Expected real noncompact tab overflow: ${JSON.stringify(geometries)}`,
1643
+ ).toBe(true);
1644
+ await expectStrip(false, 3);
1645
+ await expect
1646
+ .poll(() => scroller.evaluate(element => element.scrollWidth - element.clientWidth))
1647
+ .toBeGreaterThan(1);
1648
+ // Task insertion follows the current model entry, not creation chronology.
1649
+ // Derive the strip edges from its rendered order before testing reveal.
1650
+ const renderedOrder = await tabs.evaluateAll((elements, titles) => {
1651
+ return elements.map(element =>
1652
+ titles.findIndex(title => element.textContent!.includes(title)),
1653
+ );
1654
+ }, titles);
1655
+ expect(renderedOrder.toSorted()).toEqual([0, 1, 2]);
1656
+ const firstTask = renderedOrder[0]!;
1657
+ const lastTask = renderedOrder[renderedOrder.length - 1]!;
1658
+ await openMenu([0, 1, 2], 2);
1659
+ await choose(firstTask);
1660
+ await expectActiveRevealed(firstTask);
1661
+ await expect(viewport).not.toHaveClass(/\bhas-start\b/);
1662
+ await expect(viewport).toHaveClass(/\bhas-end\b/);
1663
+ const firstPosition = await scroller.evaluate(element => element.scrollLeft);
1664
+ const triggerBefore = (await more.boundingBox())!;
1665
+ await scroller.hover();
1666
+ await page.mouse.wheel(10_000, 0);
1667
+ await expect
1668
+ .poll(() => scroller.evaluate(element => element.scrollLeft))
1669
+ .toBeGreaterThan(firstPosition + 1);
1670
+ await expect(viewport).toHaveClass(/\bhas-start\b/);
1671
+ await expect(viewport).not.toHaveClass(/\bhas-end\b/);
1672
+ await openMenu([0, 1, 2], firstTask);
1673
+ await choose(lastTask);
1674
+ await expectActiveRevealed(lastTask);
1675
+ await openMenu([0, 1, 2], lastTask);
1676
+ await choose(firstTask);
1677
+ await expectActiveRevealed(firstTask);
1678
+ await expect(viewport).not.toHaveClass(/\bhas-start\b/);
1679
+ await openMenu([0, 1, 2], firstTask);
1680
+ await choose(lastTask);
1681
+ await expectActiveRevealed(lastTask);
1682
+ await expect
1683
+ .poll(() => scroller.evaluate(element => element.scrollLeft))
1684
+ .toBeGreaterThan(firstPosition + 1);
1685
+ const triggerAfter = (await more.boundingBox())!;
1686
+ for (const key of ['x', 'y', 'width', 'height'] as const) {
1687
+ expect(Math.abs(triggerAfter[key] - triggerBefore[key])).toBeLessThanOrEqual(1);
1688
+ }
1689
+ await openMenu([0, 1, 2], lastTask);
1690
+ await choose(2);
1691
+
1692
+ await page.setViewportSize({ width: 320, height: 900 });
1693
+ await expectStrip(true, 1);
1694
+ await expect(task(2)).toHaveAttribute('aria-selected', 'true');
1695
+ await testInfo.attach('ATP-BASIC-TABS-01-320px-header', {
1696
+ body: await items.locator('xpath=ancestor::*[contains(@class, "navbar")][1]').screenshot(),
1697
+ contentType: 'image/png',
1698
+ });
1699
+ await openMenu([0, 1, 2], 2, true);
1700
+ await testInfo.attach('ATP-BASIC-TABS-01-320px-all-tabs', {
1701
+ body: await list.screenshot(),
1702
+ contentType: 'image/png',
1703
+ });
1704
+ await select(0).focus();
1705
+ await page.keyboard.press('Escape');
1706
+ await expect(list).toBeHidden();
1707
+ await expect(more).toBeFocused();
1708
+ await expect(studentDraftNameInput(page)).toHaveValue(titles[2]!);
1709
+ await openMenu([0, 1, 2], 2, true);
1710
+ await choose(0, true);
1711
+ await expectStrip(true, 1);
1712
+ await openMenu([0, 1, 2], 0);
1713
+ await close(1);
1714
+ await expect(studentDraftNameInput(page)).toHaveValue(titles[0]!);
1715
+ await waitForStudentDraft(page, titles[0]!, students[0]!.id);
1716
+ await waitForStudentDraft(page, titles[2]!, students[2]!.id);
1717
+
1718
+ // The current workspace anchor is not a secondary task. Its compact strip
1719
+ // must be empty, but All tabs must still expose every surviving child.
1720
+ await page.locator('.tabs-lifted').getByRole('tab', { name: 'Student', exact: true }).click();
1721
+ await expect(page).toHaveURL(studentResourceUrl);
1722
+ await expectStrip(true, 0);
1723
+ await openMenu([0, 2]);
1724
+ await choose(0);
1725
+ await expectStrip(true, 1);
1726
+
1727
+ await page.setViewportSize({ width: 1440, height: 900 });
1728
+ await expectStrip(false, 2);
1729
+ await expectActiveRevealed(0);
1730
+ await openMenu([0, 2], 0, true);
1731
+ await page.keyboard.press('Escape');
1732
+ await expect(list).toBeHidden();
1733
+ await expect(more).toBeFocused();
1734
+ await openMenu([0, 2], 0);
1735
+ await close(0);
1736
+ await expect(studentDraftNameInput(page)).toHaveValue(titles[2]!);
1737
+ await expect(task(2)).toHaveAttribute('aria-selected', 'true');
1738
+ await openMenu([2], 2);
1739
+ await close(2);
1740
+ await expect(page).toHaveURL(studentResourceUrl);
1741
+ await expect(tabs).toHaveCount(0);
1742
+ for (const student of students) await expectNoStudentDraft(page, student.id);
1743
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
1744
+ expect(effects.mutations).toEqual([]);
1745
+ expect(effects.hydrationWarnings).toEqual([]);
1746
+ expect(effects.consoleErrors).toEqual([]);
1747
+ expect(effects.pageErrors).toEqual([]);
1748
+ });
1749
+ },
1750
+ );
1751
+
678
1752
  test(
679
1753
  'ATP-BASIC-FLOW-01: cold Admin navigation initializes dynamic resource selectors',
680
1754
  { tag: ['@admin', '@flow'] },
@@ -1205,6 +2279,168 @@ test(
1205
2279
  },
1206
2280
  );
1207
2281
 
2282
+ test(
2283
+ 'ATP-BASIC-TABLE-05: mobile-UA SSR starts compact before scripts and container width wins after hydration',
2284
+ { tag: ['@admin', '@flow', '@ssr'] },
2285
+ async ({ page, browser }) => {
2286
+ test.setTimeout(120_000);
2287
+ await page.setViewportSize({ width: 1440, height: 900 });
2288
+ await loginAsAdmin(page);
2289
+ await withDraftStudents(page, 1, async ([student]) => {
2290
+ const loaded = waitForStudentSelect(page);
2291
+ await page.getByRole('link', { name: 'Student', exact: true }).click();
2292
+ await expect(page).toHaveURL(studentResourceUrl);
2293
+ await loaded;
2294
+ const listUrl = page.url();
2295
+ const storageState = await page.context().storageState();
2296
+ for (const scenario of [
2297
+ { mobile: true, width: 390, unsupportedPopover: false },
2298
+ { mobile: true, width: 1440, unsupportedPopover: false },
2299
+ { mobile: false, width: 390, unsupportedPopover: false },
2300
+ { mobile: true, width: 390, unsupportedPopover: true },
2301
+ ]) {
2302
+ const context = await browser.newContext({
2303
+ baseURL: new URL(listUrl).origin,
2304
+ storageState,
2305
+ userAgent: scenario.mobile ? mobileUserAgent : desktopUserAgent,
2306
+ viewport: { width: scenario.width, height: 900 },
2307
+ });
2308
+ try {
2309
+ if (scenario.unsupportedPopover) {
2310
+ await context.addInitScript(() => {
2311
+ Object.defineProperty(HTMLElement.prototype, 'showPopover', {
2312
+ configurable: true,
2313
+ value: undefined,
2314
+ });
2315
+ });
2316
+ }
2317
+ const ssrPage = await context.newPage();
2318
+ const pageErrors = collectPageErrors(ssrPage);
2319
+ const consoleErrors = collectConsoleErrors(ssrPage);
2320
+ const hydrationWarnings: string[] = [];
2321
+ ssrPage.on('console', message => {
2322
+ if (/hydration.*mismatch|mismatch.*hydration/i.test(message.text())) {
2323
+ hydrationWarnings.push(message.text());
2324
+ }
2325
+ });
2326
+ let releaseScripts!: () => void;
2327
+ const scriptsHeld = new Promise<void>(resolve => {
2328
+ releaseScripts = resolve;
2329
+ });
2330
+ let heldScripts = 0;
2331
+ const holdExternalScripts = async (route: Route) => {
2332
+ if (route.request().resourceType() === 'script') {
2333
+ heldScripts++;
2334
+ await scriptsHeld;
2335
+ }
2336
+ await route.continue();
2337
+ };
2338
+ await ssrPage.route('**/*', holdExternalScripts);
2339
+ try {
2340
+ const response = await ssrPage.goto(listUrl, { waitUntil: 'commit' });
2341
+ expect(response?.ok()).toBeTruthy();
2342
+ expect(response?.headers()['cache-control']).toBe('private, no-store');
2343
+ expect((response?.headers().vary ?? '').toLowerCase().split(/\s*,\s*/)).not.toContain(
2344
+ 'user-agent',
2345
+ );
2346
+ const wrapper = basicTableWrapper(ssrPage);
2347
+ const row = wrapper.locator('tbody tr').filter({
2348
+ has: ssrPage.getByRole('link', { name: student.name, exact: true }),
2349
+ });
2350
+ const operationsHeader = wrapper.getByRole('columnheader', {
2351
+ name: 'Operations',
2352
+ exact: true,
2353
+ });
2354
+ const operationsCell = row.locator('td').last();
2355
+ const more = row.getByRole('button', { name: 'More actions', exact: true });
2356
+ const actions = row.locator('[popover="auto"][role="group"][aria-label="Row actions"]');
2357
+ await expect(row).toHaveCount(1);
2358
+ await ssrPage.waitForFunction(() =>
2359
+ Array.from(
2360
+ document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'),
2361
+ ).every(link => link.sheet !== null),
2362
+ );
2363
+ await expect.poll(() => heldScripts).toBeGreaterThan(0);
2364
+ await expect(ssrPage.locator('html')).not.toHaveAttribute('data-zova-hydrated');
2365
+ await expect(wrapper).toHaveAttribute('data-table-compact', String(scenario.mobile));
2366
+ if (scenario.mobile) {
2367
+ for (const cell of [operationsHeader, operationsCell]) {
2368
+ await expect(cell).toHaveCSS('width', '80px');
2369
+ await expect(cell).toHaveCSS('min-width', '80px');
2370
+ }
2371
+ const heading = operationsHeader.locator('.sr-only');
2372
+ await expect(heading).toHaveText('Operations');
2373
+ await expect(heading).toHaveCSS('position', 'absolute');
2374
+ await expect(heading).toHaveCSS('width', '1px');
2375
+ await expect(more).toBeVisible();
2376
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
2377
+ await expect(actions).toHaveCount(1);
2378
+ await expect(actions).toBeHidden();
2379
+ await expect(actions.locator('button')).toHaveCount(4);
2380
+ await expect(row.getByRole('button')).toHaveCount(1);
2381
+ } else {
2382
+ await expect(more).toHaveCount(0);
2383
+ await expect(row.getByRole('button')).toHaveCount(4);
2384
+ }
2385
+
2386
+ releaseScripts();
2387
+ await ssrPage.waitForLoadState('load');
2388
+ await expect(ssrPage.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2389
+ const measuredCompact = scenario.width < 768 && !scenario.unsupportedPopover;
2390
+ await expect(wrapper).toHaveAttribute('data-table-compact', String(measuredCompact));
2391
+ if (measuredCompact) {
2392
+ await expect(operationsHeader).toHaveCSS('min-width', '80px');
2393
+ const actionsId = await actions.getAttribute('id');
2394
+ expect(actionsId).toBeTruthy();
2395
+ await expect(more).toHaveAttribute('popovertarget', actionsId!);
2396
+ await expect(more).toHaveAttribute('aria-controls', actionsId!);
2397
+ await more.scrollIntoViewIfNeeded();
2398
+ await more.click();
2399
+ await expect(actions).toBeVisible();
2400
+ await expect(
2401
+ actions.getByRole('button', { name: 'Summary', exact: true }),
2402
+ ).toBeVisible();
2403
+ await ssrPage.keyboard.press('Escape');
2404
+ await expect(actions).toBeHidden();
2405
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
2406
+ } else {
2407
+ if (!scenario.unsupportedPopover) {
2408
+ expect(
2409
+ await wrapper.evaluate(element => element.clientWidth),
2410
+ ).toBeGreaterThanOrEqual(768);
2411
+ }
2412
+ await expect(operationsHeader).toHaveCSS('min-width', '360px');
2413
+ await expect(more).toHaveCount(0);
2414
+ await expect(row.getByRole('button')).toHaveCount(4);
2415
+ if (scenario.unsupportedPopover) {
2416
+ const summary = row.getByRole('button', { name: 'Summary', exact: true });
2417
+ await summary.scrollIntoViewIfNeeded();
2418
+ await summary.click();
2419
+ const dialog = ssrPage.getByRole('dialog');
2420
+ await expect(dialog).toBeVisible();
2421
+ await expect(
2422
+ dialog.getByRole('heading', { name: 'Summary', exact: true }),
2423
+ ).toBeVisible();
2424
+ await ssrPage.keyboard.press('Escape');
2425
+ await expect(dialog).toHaveCount(0);
2426
+ }
2427
+ }
2428
+ expect(hydrationWarnings).toEqual([]);
2429
+ expect(consoleErrors).toEqual([]);
2430
+ expect(pageErrors).toEqual([]);
2431
+ } finally {
2432
+ releaseScripts();
2433
+ // Drain released handlers before closing their context, preserving the first failure.
2434
+ await ssrPage.unrouteAll({ behavior: 'wait' });
2435
+ }
2436
+ } finally {
2437
+ await context.close();
2438
+ }
2439
+ }
2440
+ });
2441
+ },
2442
+ );
2443
+
1208
2444
  test(
1209
2445
  'ATP-BASIC-TABLE-03: compact row delete handles cancellation, local failures, and retry',
1210
2446
  { tag: ['@admin', '@flow'] },