|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#29566 — Studio's cross-package list (`/studio/~all/metadata/<type>`) |
| 5 | + * opens and stays open, through the area's real routes and the real list page. |
| 6 | + * |
| 7 | + * The list used to repair `?package=` whenever the area named no package, so |
| 8 | + * the cross-package leg became `?package=<first>` and the area then moved it |
| 9 | + * onto that package's leg (`areaLocationRedirect`). The list of a type across |
| 10 | + * packages could not be opened, with or without `?package=all`, which is where |
| 11 | + * every package-less Setup metadata URL and the Packaged automation bookmark |
| 12 | + * land (`setupMetadataRedirect.ts`). |
| 13 | + * |
| 14 | + * Controls: a package's area keeps its leg and lists that package's rows, and |
| 15 | + * Setup's list (no area) still repairs `?package=` to a project package. |
| 16 | + */ |
| 17 | + |
| 18 | +import '@testing-library/jest-dom/vitest'; |
| 19 | +import * as React from 'react'; |
| 20 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 21 | +import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'; |
| 22 | +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; |
| 23 | + |
| 24 | +const PROJECT = 'com.example.crm'; |
| 25 | +const OTHER = 'com.example.hr'; |
| 26 | +const PLATFORM = 'com.objectstack.plugin-security'; |
| 27 | + |
| 28 | +/** Rows of two project packages, a platform package, and one that belongs to no package. */ |
| 29 | +const ROWS: Record<string, Array<Record<string, unknown>>> = { |
| 30 | + object: [ |
| 31 | + { name: 'crm_account', label: 'Account', _packageId: PROJECT }, |
| 32 | + { name: 'hr_employee', label: 'Employee', _packageId: OTHER }, |
| 33 | + { name: 'sys_user', label: 'User', _packageId: PLATFORM }, |
| 34 | + { name: 'env_note', label: 'Note' }, |
| 35 | + ], |
| 36 | + flow: [ |
| 37 | + { name: 'crm_followup', label: 'Follow up', _packageId: PROJECT }, |
| 38 | + { name: 'hr_onboard', label: 'Onboard', _packageId: OTHER }, |
| 39 | + ], |
| 40 | +}; |
| 41 | + |
| 42 | +const CLIENT = { |
| 43 | + list: async (type: string) => |
| 44 | + type === 'package' |
| 45 | + ? [ |
| 46 | + { manifest: { id: OTHER, scope: 'project', name: 'HR' } }, |
| 47 | + { manifest: { id: PROJECT, scope: 'project', name: 'CRM' } }, |
| 48 | + { manifest: { id: PLATFORM, scope: 'system', name: 'Security' } }, |
| 49 | + ] |
| 50 | + : (ROWS[type] ?? []), |
| 51 | +}; |
| 52 | + |
| 53 | +const entry = (type: string, domain: string) => ({ |
| 54 | + type, |
| 55 | + label: type, |
| 56 | + domain, |
| 57 | + allowEnvironmentOverlay: false, |
| 58 | + allowRuntimeCreate: true, |
| 59 | +}); |
| 60 | + |
| 61 | +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => ({ |
| 62 | + ...(await importOriginal<Record<string, unknown>>()), |
| 63 | + useMetadataClient: () => CLIENT, |
| 64 | + useMetadataTypes: () => ({ loading: false, error: null, entries: [entry('object', 'data'), entry('flow', 'automation')] }), |
| 65 | +})); |
| 66 | +vi.mock('../metadata-admin/i18n', async (importOriginal) => ({ |
| 67 | + ...(await importOriginal<Record<string, unknown>>()), |
| 68 | + useMetadataLocale: () => 'en-US', |
| 69 | +})); |
| 70 | +vi.mock('../../hooks/useCanAuthorMetadata', async (importOriginal) => ({ |
| 71 | + ...(await importOriginal<Record<string, unknown>>()), |
| 72 | + useCanAuthorMetadata: () => true, |
| 73 | +})); |
| 74 | +/** `GET /api/v1/packages` as the area's writable probe reads it. */ |
| 75 | +vi.mock('./packages-io.js', async (importOriginal) => ({ |
| 76 | + ...(await importOriginal<Record<string, unknown>>()), |
| 77 | + fetchListedPackageManifests: vi.fn( |
| 78 | + async () => |
| 79 | + new Map([ |
| 80 | + [PROJECT, { id: PROJECT, name: 'CRM', writable: true, manifest: {} }], |
| 81 | + [OTHER, { id: OTHER, name: 'HR', writable: true, manifest: {} }], |
| 82 | + ]), |
| 83 | + ), |
| 84 | +})); |
| 85 | + |
| 86 | +import { StudioMetadataArea } from './StudioMetadataArea'; |
| 87 | +import { MetadataResourceListPage } from '../metadata-admin/ResourceListPage'; |
| 88 | +import { t } from '../metadata-admin/i18n'; |
| 89 | + |
| 90 | +afterEach(cleanup); |
| 91 | + |
| 92 | +// A package's list reads its customizations (`usePackageCustomizations`); this |
| 93 | +// file pins scope, not marks, so that read answers 404 (no mark is drawn). |
| 94 | +// One double at module scope, left up, so no late read reaches a socket. |
| 95 | +vi.stubGlobal( |
| 96 | + 'fetch', |
| 97 | + vi.fn(async () => new Response(JSON.stringify({ success: false }), { status: 404, headers: { 'Content-Type': 'application/json' } })), |
| 98 | +); |
| 99 | + |
| 100 | +function Where() { |
| 101 | + const { pathname, search } = useLocation(); |
| 102 | + return <div data-testid="where">{`${pathname}${search}`}</div>; |
| 103 | +} |
| 104 | + |
| 105 | +function PackageArea() { |
| 106 | + const { pathname } = useLocation(); |
| 107 | + const packageId = decodeURIComponent(pathname.split('/')[2] ?? ''); |
| 108 | + return <StudioMetadataArea key={packageId} packageId={packageId} />; |
| 109 | +} |
| 110 | + |
| 111 | +/** The console's two area routes, plus Setup's list route with no area around it. */ |
| 112 | +function mount(url: string) { |
| 113 | + return render( |
| 114 | + <MemoryRouter initialEntries={[url]}> |
| 115 | + <Where /> |
| 116 | + <Routes> |
| 117 | + <Route path="/studio/~all/metadata/*" element={<StudioMetadataArea packageId={null} />} /> |
| 118 | + <Route path="/studio/:packageId/metadata/*" element={<PackageArea />} /> |
| 119 | + <Route path="/apps/:appName/metadata/:type" element={<MetadataResourceListPage />} /> |
| 120 | + </Routes> |
| 121 | + </MemoryRouter>, |
| 122 | + ); |
| 123 | +} |
| 124 | + |
| 125 | +const where = () => screen.getByTestId('where').textContent ?? ''; |
| 126 | +const row = (name: string) => screen.queryByTestId(`metadata-row-${name}`); |
| 127 | +const rowHref = (container: HTMLElement, name: string) => |
| 128 | + container.querySelector<HTMLAnchorElement>(`a[href*="/${name}"]`)?.getAttribute('href') ?? null; |
| 129 | +const WAIT = { timeout: 10_000 }; |
| 130 | + |
| 131 | +describe('the cross-package list stays open (objectstack#29566)', () => { |
| 132 | + for (const query of ['', '?package=all']) { |
| 133 | + it(`/studio/~all/metadata/object${query} lists every package's rows and keeps its URL`, async () => { |
| 134 | + const { container } = mount(`/studio/~all/metadata/object${query}`); |
| 135 | + await waitFor(() => expect(row('crm_account')).not.toBeNull(), WAIT); |
| 136 | + for (const name of ['hr_employee', 'sys_user', 'env_note']) expect(row(name)).not.toBeNull(); |
| 137 | + // Each row opens its owning package; a package-less row opens by name. |
| 138 | + expect(rowHref(container, 'crm_account')).toBe(`/studio/~all/metadata/object/crm_account?package=${PROJECT}`); |
| 139 | + expect(rowHref(container, 'hr_employee')).toBe(`/studio/~all/metadata/object/hr_employee?package=${OTHER}`); |
| 140 | + expect(rowHref(container, 'env_note')).toBe('/studio/~all/metadata/object/env_note'); |
| 141 | + // Every load has settled (the package list, the rows); nothing rewrote the URL. |
| 142 | + await new Promise((r) => setTimeout(r, 50)); |
| 143 | + expect(where()).toBe(`/studio/~all/metadata/object${query}`); |
| 144 | + }); |
| 145 | + } |
| 146 | + |
| 147 | + it('the Packaged automation landing, /studio/~all/metadata/flow, lists the flows of every package', async () => { |
| 148 | + mount('/studio/~all/metadata/flow'); |
| 149 | + await waitFor(() => expect(row('crm_followup')).not.toBeNull(), WAIT); |
| 150 | + expect(row('hr_onboard')).not.toBeNull(); |
| 151 | + await new Promise((r) => setTimeout(r, 50)); |
| 152 | + expect(where()).toBe('/studio/~all/metadata/flow'); |
| 153 | + }); |
| 154 | + |
| 155 | + it('create alone picks a package, and opens that package\'s create', async () => { |
| 156 | + mount('/studio/~all/metadata/object'); |
| 157 | + await waitFor(() => expect(row('crm_account')).not.toBeNull(), WAIT); |
| 158 | + fireEvent.click(screen.getAllByRole('button', { name: new RegExp(t('engine.list.create', 'en-US')) })[0]); |
| 159 | + await waitFor(() => expect(where()).toBe(`/studio/${PROJECT}/metadata/object/new?package=${PROJECT}`), WAIT); |
| 160 | + }); |
| 161 | +}); |
| 162 | + |
| 163 | +describe('controls: a package area and Setup keep their scope', () => { |
| 164 | + it('a package\'s area keeps its leg and lists its own rows', async () => { |
| 165 | + mount(`/studio/${PROJECT}/metadata/object?package=${PROJECT}`); |
| 166 | + await waitFor(() => expect(row('crm_account')).not.toBeNull(), WAIT); |
| 167 | + expect(row('hr_employee')).toBeNull(); |
| 168 | + expect(row('sys_user')).toBeNull(); |
| 169 | + expect(row('env_note')).toBeNull(); |
| 170 | + expect(where()).toBe(`/studio/${PROJECT}/metadata/object?package=${PROJECT}`); |
| 171 | + }); |
| 172 | + |
| 173 | + it('Setup\'s list (no area) still repairs `?package=` onto a project package', async () => { |
| 174 | + mount('/apps/setup/metadata/object'); |
| 175 | + await waitFor(() => expect(where()).toBe(`/apps/setup/metadata/object?package=${PROJECT}`), WAIT); |
| 176 | + await waitFor(() => expect(row('crm_account')).not.toBeNull(), WAIT); |
| 177 | + expect(row('hr_employee')).toBeNull(); |
| 178 | + expect(row('sys_user')).toBeNull(); |
| 179 | + }); |
| 180 | +}); |
0 commit comments