|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#28118 — a suite's `type: 'module'` row offers Disable / Enable |
| 5 | + * from the disable door's served verdict, `disableable` (objectstack#28318), |
| 6 | + * and nothing else it cannot author. |
| 7 | + * |
| 8 | + * A module of an installed app is served `writable: false` (read-only for |
| 9 | + * authoring) and `disableable: true` (the disable door accepts it). Pinned |
| 10 | + * through the real `PackageDetailSheet`, and through the real page in the |
| 11 | + * Installed Apps view (`?source=marketplace`), where a marketplace-installed |
| 12 | + * suite's modules are listed as their own rows: |
| 13 | + * (1) a module row with the verdict shows Disable, which confirms with the |
| 14 | + * existing consequence copy and sends the one `PATCH …/disable`; |
| 15 | + * (2) every other write action stays hidden on it, and its read-only reason |
| 16 | + * says it can be disabled; |
| 17 | + * (3) a read-only row without the verdict offers no Disable (served `false`, |
| 18 | + * or absent: anything but `true`); |
| 19 | + * (4) a disabled module row shows Enable, which runs at once; |
| 20 | + * (5) the verdict is read, never re-derived: `manifest.type` alone moves |
| 21 | + * nothing. |
| 22 | + */ |
| 23 | + |
| 24 | +import '@testing-library/jest-dom/vitest'; |
| 25 | +import * as React from 'react'; |
| 26 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 27 | +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; |
| 28 | +import { MemoryRouter } from 'react-router-dom'; |
| 29 | +import { I18nProvider } from '@object-ui/i18n'; |
| 30 | + |
| 31 | +vi.mock('@object-ui/auth', async (importOriginal) => ({ |
| 32 | + ...(await importOriginal<Record<string, unknown>>()), |
| 33 | + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), |
| 34 | +})); |
| 35 | + |
| 36 | +import { PackageDetailSheet, PackagesPage, type InstalledPackageRow } from './PackagesPage'; |
| 37 | +import { t } from './i18n'; |
| 38 | +import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../runtime-config'; |
| 39 | + |
| 40 | +const label = (key: string) => t(`engine.packages.detail.${key}`, 'en'); |
| 41 | + |
| 42 | +/** The write actions a writable base offers besides Disable. */ |
| 43 | +const OTHER_WRITES = ['edit', 'publish', 'revert', 'duplicate', 'adoptOrphans', 'deletePackage']; |
| 44 | + |
| 45 | +const MODULE_ID = 'com.acme.suite.orders'; |
| 46 | + |
| 47 | +function moduleRow( |
| 48 | + extra: { enabled?: boolean; scope?: 'project'; disableable?: unknown; type?: string } = {}, |
| 49 | +): InstalledPackageRow { |
| 50 | + return { |
| 51 | + manifest: { |
| 52 | + id: MODULE_ID, |
| 53 | + name: 'Acme Suite Orders', |
| 54 | + version: '1.0.0', |
| 55 | + type: extra.type ?? 'module', |
| 56 | + ...(extra.scope ? { scope: extra.scope } : {}), |
| 57 | + }, |
| 58 | + enabled: extra.enabled ?? true, |
| 59 | + source: 'marketplace', |
| 60 | + writable: false, |
| 61 | + // `disableable: undefined` stands for a row that carries no such key. |
| 62 | + ...(!('disableable' in extra) |
| 63 | + ? { disableable: true } |
| 64 | + : extra.disableable === undefined |
| 65 | + ? {} |
| 66 | + : { disableable: extra.disableable as boolean }), |
| 67 | + }; |
| 68 | +} |
| 69 | + |
| 70 | +const reply = (status: number, body: unknown) => |
| 71 | + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); |
| 72 | + |
| 73 | +let calls: { path: string; method: string }[]; |
| 74 | + |
| 75 | +/** Serve the drafts read, the lifecycle doors and (for the page) the list door. */ |
| 76 | +function stubWire(packages: unknown[] = []) { |
| 77 | + calls = []; |
| 78 | + vi.stubGlobal( |
| 79 | + 'fetch', |
| 80 | + vi.fn(async (input: unknown, init?: { method?: string }) => { |
| 81 | + const path = new URL(String(input), 'http://console.test').pathname; |
| 82 | + const method = (init?.method ?? 'GET').toUpperCase(); |
| 83 | + calls.push({ path, method }); |
| 84 | + if (path.startsWith('/api/v1/meta/_drafts')) return reply(200, { success: true, data: { drafts: [] } }); |
| 85 | + if (path === '/api/v1/packages' && method === 'GET') return reply(200, { success: true, data: { packages } }); |
| 86 | + if (path === '/api/v1/cloud-connection/installed') return reply(404, {}); |
| 87 | + if (path === '/api/v1/marketplace/install-local' && method === 'GET') { |
| 88 | + return reply(200, { success: true, data: { items: [] } }); |
| 89 | + } |
| 90 | + return reply(200, { success: true, data: {} }); |
| 91 | + }), |
| 92 | + ); |
| 93 | +} |
| 94 | + |
| 95 | +/** The lifecycle PATCHes sent: `enable` / `disable`. */ |
| 96 | +const lifecycleCalls = () => |
| 97 | + calls |
| 98 | + .filter((c) => c.method === 'PATCH' && /\/(enable|disable)$/.test(c.path)) |
| 99 | + .map((c) => c.path.split('/').pop()); |
| 100 | + |
| 101 | +const settle = () => |
| 102 | + act(async () => { |
| 103 | + await new Promise((r) => setTimeout(r, 0)); |
| 104 | + }); |
| 105 | + |
| 106 | +async function renderSheet(pkg: InstalledPackageRow) { |
| 107 | + render( |
| 108 | + <MemoryRouter> |
| 109 | + <PackageDetailSheet pkg={pkg} open onOpenChange={() => {}} onChanged={() => {}} /> |
| 110 | + </MemoryRouter>, |
| 111 | + ); |
| 112 | + await screen.findByTestId('pkg-scope'); |
| 113 | + await settle(); |
| 114 | +} |
| 115 | + |
| 116 | +const offered = (keys: string[]) => keys.filter((k) => screen.queryByRole('button', { name: label(k) }) !== null); |
| 117 | + |
| 118 | +afterEach(() => { |
| 119 | + cleanup(); |
| 120 | + vi.unstubAllGlobals(); |
| 121 | + resetRuntimeConfigForTesting(); |
| 122 | +}); |
| 123 | + |
| 124 | +describe('PackageDetailSheet — a module row with the served verdict (objectstack#28118)', () => { |
| 125 | + beforeEach(() => stubWire()); |
| 126 | + |
| 127 | + it('(1) shows Disable, which opens the confirm and sends nothing until it is confirmed', async () => { |
| 128 | + await renderSheet(moduleRow()); |
| 129 | + fireEvent.click(screen.getByRole('button', { name: label('disable') })); |
| 130 | + const dialog = await screen.findByTestId('pkg-detail-disable-dialog'); |
| 131 | + expect(within(dialog).getByText(/Acme Suite Orders/)).toBeInTheDocument(); |
| 132 | + expect(within(dialog).getByText(label('disableDescription'))).toBeInTheDocument(); |
| 133 | + expect(lifecycleCalls()).toEqual([]); |
| 134 | + |
| 135 | + fireEvent.click(within(dialog).getByTestId('pkg-detail-disable-confirm')); |
| 136 | + await vi.waitFor(() => expect(lifecycleCalls()).toEqual(['disable'])); |
| 137 | + expect(calls.find((c) => c.method === 'PATCH')?.path).toBe(`/api/v1/packages/${MODULE_ID}/disable`); |
| 138 | + }); |
| 139 | + |
| 140 | + it('(2) keeps every other write action hidden: the module stays read-only for authoring', async () => { |
| 141 | + await renderSheet(moduleRow()); |
| 142 | + expect(offered(OTHER_WRITES)).toEqual([]); |
| 143 | + expect(screen.getByTestId('pkg-scope')).toHaveTextContent(t('engine.packages.scope.readOnly', 'en')); |
| 144 | + }); |
| 145 | + |
| 146 | + it.each([ |
| 147 | + ['a scope-less module (installed from the marketplace)', moduleRow(), 'readOnlyDisableable'], |
| 148 | + ['a project-scoped module (booted from code)', moduleRow({ scope: 'project' }), 'codeReadOnlyDisableable'], |
| 149 | + ])('(2) %s states that it is read-only and can be disabled', async (_name, pkg, key) => { |
| 150 | + await renderSheet(pkg); |
| 151 | + expect(screen.getByTestId('pkg-detail-readonly-reason')).toHaveTextContent(label(key)); |
| 152 | + }); |
| 153 | + |
| 154 | + it('(4) a disabled module row shows Enable, which runs at once with no dialog', async () => { |
| 155 | + await renderSheet(moduleRow({ enabled: false })); |
| 156 | + expect(screen.queryByRole('button', { name: label('disable') })).toBeNull(); |
| 157 | + fireEvent.click(screen.getByRole('button', { name: label('enable') })); |
| 158 | + await vi.waitFor(() => expect(lifecycleCalls()).toEqual(['enable'])); |
| 159 | + expect(screen.queryByTestId('pkg-detail-disable-dialog')).toBeNull(); |
| 160 | + expect(offered(OTHER_WRITES)).toEqual([]); |
| 161 | + }); |
| 162 | +}); |
| 163 | + |
| 164 | +describe('PackageDetailSheet — a read-only row without the verdict (objectstack#28118)', () => { |
| 165 | + beforeEach(() => stubWire()); |
| 166 | + |
| 167 | + it.each([ |
| 168 | + ['served disableable: false', moduleRow({ disableable: false })], |
| 169 | + ['no disableable key at all', moduleRow({ disableable: undefined })], |
| 170 | + ])('(3) %s offers no Disable and keeps the plain read-only reason', async (_name, pkg) => { |
| 171 | + await renderSheet(pkg); |
| 172 | + expect(screen.queryByRole('button', { name: label('disable') })).toBeNull(); |
| 173 | + expect(screen.queryByRole('button', { name: label('enable') })).toBeNull(); |
| 174 | + expect(screen.getByTestId('pkg-detail-readonly-reason')).toHaveTextContent(label('readOnly')); |
| 175 | + }); |
| 176 | + |
| 177 | + it('(5) the verdict decides, not manifest.type: a non-module row served disableable offers Disable', async () => { |
| 178 | + await renderSheet(moduleRow({ type: 'app' })); |
| 179 | + expect(screen.getByRole('button', { name: label('disable') })).toBeInTheDocument(); |
| 180 | + expect(offered(OTHER_WRITES)).toEqual([]); |
| 181 | + }); |
| 182 | +}); |
| 183 | + |
| 184 | +describe('Installed Apps view — a marketplace suite\'s module row (objectstack#28118)', () => { |
| 185 | + const APP_ROW: InstalledPackageRow = { |
| 186 | + manifest: { id: 'com.acme.suite', name: 'Acme Suite', version: '1.0.0', type: 'app' }, |
| 187 | + enabled: true, |
| 188 | + source: 'marketplace', |
| 189 | + writable: false, |
| 190 | + disableable: false, |
| 191 | + }; |
| 192 | + |
| 193 | + async function renderInstalledApps() { |
| 194 | + resetRuntimeConfigForTesting(); |
| 195 | + vi.stubGlobal('fetch', vi.fn(async () => reply(200, { features: { installLocal: true } }))); |
| 196 | + await initRuntimeConfig(); |
| 197 | + stubWire([APP_ROW, moduleRow()]); |
| 198 | + render( |
| 199 | + <I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}> |
| 200 | + <MemoryRouter initialEntries={['/apps/setup/component/developer/packages?source=marketplace']}> |
| 201 | + <PackagesPage /> |
| 202 | + </MemoryRouter> |
| 203 | + </I18nProvider>, |
| 204 | + ); |
| 205 | + } |
| 206 | + |
| 207 | + it('lists the module as its own row, and its sheet offers Disable', async () => { |
| 208 | + await renderInstalledApps(); |
| 209 | + fireEvent.click(await screen.findByTestId(`pkg-row-${MODULE_ID}`)); |
| 210 | + expect(await screen.findByRole('button', { name: label('disable') })).toBeInTheDocument(); |
| 211 | + expect(offered(OTHER_WRITES)).toEqual([]); |
| 212 | + }); |
| 213 | + |
| 214 | + it('CONTROL: the suite\'s app row, served disableable: false, offers no Disable', async () => { |
| 215 | + await renderInstalledApps(); |
| 216 | + fireEvent.click(await screen.findByTestId('pkg-row-com.acme.suite')); |
| 217 | + // The sheet is open once its read actions are drawn. |
| 218 | + await screen.findByRole('button', { name: label('viewInfo') }); |
| 219 | + await settle(); |
| 220 | + expect(screen.queryByRole('button', { name: label('disable') })).toBeNull(); |
| 221 | + }); |
| 222 | +}); |
0 commit comments