Files
6d83054647 chore(deps): migrate msw 1.x → 2.13.4 across admin test suites (#26065)
* chore(deps): bump msw to 1.3.5 and typedoc to 0.25.13

Widens TypeScript peer ranges in msw (>= 4.4.x) and typedoc (adds 5.4.x),
unblocking a future bump to typescript 5.6 required by react-intl 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(deps): bump msw to 2.13.4 across admin test suites

Migrates 8 workspaces (admin, content-manager, content-releases, upload,
review-workflows, plugin-i18n, plugin-documentation, plugin-users-permissions)
from msw 1.3.5 to 2.13.4; bumps email's unused dep for consistency.

Handler changes:
- rest.X((req, res, ctx) => res(ctx.json(X))) → http.X(() => HttpResponse.json(X))
- res(ctx.status(N)) → new HttpResponse(null, { status: N })
- res.once(...) → http.X(path, handler, { once: true })
- res.networkError(...) → HttpResponse.error()
- await req.json<T>() / casts → http.METHOD<Params, Body>(...) generics
- RequestHandler → HttpHandler
- Empty 200 responses use HttpResponse.json({}) to avoid cross-realm
  SyntaxError instanceof check in getFetchClient.ts

Jest setup (per msw frequent-issues guide):
- jest-preset.front.js: customExportConditions: [''] so msw/node resolves
  under jsdom; .mjs swc transform; ESM allow-list for msw deps
  (rettime, @open-draft, outvariant, until-async, etc.)
- packages/admin-test-utils/src/environment.ts: polyfill Node fetch +
  streams + BroadcastChannel + URL into jsdom global. Blob/File/FormData
  intentionally left as jsdom's — Strapi app code threads them through
  jsdom-only APIs (FileReader, Image, HTMLFormElement) where realm matters.
  Comment in env.ts documents the trade-off.

Verified vs develop+msw@1.3.5 baseline: 0 net regressions across all 7
workspaces with msw test files; TypeScript clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "chore(deps): bump typedoc to 0.25.13"

The typedoc bump was bundled with the msw 1.3.5 bump in 39d169cfbb but
isn't required for the msw v2 migration. Reverting the docs/ and
core/types typedoc dep back to 0.25.10 to keep this branch focused on
the msw upgrade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(tests): type msw handler params via generics

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(tests): address review feedback on msw v2 migration

- env.ts: snapshot jsdom globals in setup(), restore in teardown() so
  per-test mutations cannot leak across files in a Jest worker.
- env.ts: collapse polyfill list and value map into a single NODE_GLOBALS
  source of truth; document the fetch trade-off (Node undici vs jsdom).
- handlers: replace `<never, Body>` generics with `<Record<string, never>,
  Body>` (msw v2 convention; `{}` blocked by ban-types).
- email: drop unused msw devDep entirely (zero source usage in workspace).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(tests): use realm-agnostic SyntaxError check; drop URL polyfill

Two follow-ups to the msw v2 migration:

- `getFetchClient.responseInterceptor` now matches `error?.name === 'SyntaxError'`
  rather than `instanceof SyntaxError`. Constructor identity differs across JS realms
  (under jsdom + Node fetch, an empty 200 throws a Node-realm `SyntaxError` that
  fails the jsdom-realm `instanceof` check; the same is true in browsers when the
  Response originates from a service worker / iframe). Name comparison is realm-
  agnostic. With this in place, empty 200 handlers can return `new HttpResponse()`
  again (closer to v1's `res(ctx.status(200))`) instead of the `HttpResponse.json({})`
  workaround.

- Drop `URL` / `URLSearchParams` from the env.ts polyfill list. jsdom already
  provides browser-spec versions, and Node's `URL.createObjectURL` only accepts
  Node-realm `Blob` — replacing it broke the "from computer" upload path in
  `LogoInput.tsx`. MSW v2's interceptor doesn't require these to come from Node.

env.ts comments restructured: all polyfill rationale lives in one top-of-file block
so the body is short and the trade-offs are documented in one place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(tests): switch content-type-builder jest env to shared polyfilled env

`aiClient.test.ts` constructs `new Response(...)` which jsdom does not provide
spec-compliantly. Align CTB with the other admin test suites by using
`@strapi/admin-test-utils/environment`, which polyfills Node's
`fetch`/`Request`/`Response`/streams into the jsdom global.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): make msw v2 + undici body + jsdom realms work together

Three regressions surfaced by running the full front test suite under
jsdom + Node's undici fetch polyfill:

1. `TypeError: Body is unusable: Body has already been read` in every
   handler doing `await request.json()`. MSW v2's `RequestHandler.run()`
   calls `request.clone()` before invoking the resolver (to keep a copy
   for post-hoc logging). Under Node's undici, `Request.clone()` tees the
   underlying `ReadableStream` and locks the original body, so the
   resolver's `.json()` throws. Fix in `environment.ts`: wrap the global
   `Request` constructor to stash the stringified body on every instance
   and patch `Request.prototype.json` / `.text` to return the stash when
   present. Handlers now get the body regardless of how many times msw
   clones internally.

2. `expect.any(Array)` and `toStrictEqual` failing with "serializes to
   the same string" against msw response bodies. `Response.prototype.json`
   runs Node's `JSON.parse`, so the resulting objects carry Node-realm
   `Object`/`Array` prototypes; jsdom's `instanceof`/strict-equality
   matchers reject them. Patch `Response.prototype.json` to parse with
   the jsdom-realm `JSON`.

3. `useWidgets > should handle network errors gracefully` expected the
   toast text "Network error", which msw v1's `res.networkError('Network
   error')` surfaced verbatim. v2's `HttpResponse.error()` drops the
   custom message and fetch throws `TypeError: Failed to fetch`; update
   the assertion accordingly.

Also: `UIDInput` uses `jest.useFakeTimers()`. Adding `doNotFake:
['queueMicrotask', 'setImmediate']` keeps per-test runtime at ~1s
instead of ~10s (plain fake timers stall undici's internal microtasks
between request emission and handler resolution), and an `afterEach`
restores real timers so a failing test cannot leak fake timers into
the next one.

Verified with `nx run-many --target=test:front --parallel=1` across all
12 projects — all suites green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(admin): assert toast container in Widgets error tests

Replace text-match (`findByText('Server error' | 'Failed to fetch')`) with
`[data-sonner-toast]` container assertion. Decouples the test from toast
copy and from msw v1/v2 network-error message differences.

Addresses @jhoward1994's review comment on PR #26065.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(admin): assert toast text, scope msw handlers, use jsdom env

- Widgets tests: match actual toast text (Server error / fetch failure
  regex) instead of data-sonner-toast container.
- ConfirmBulkActionDialog: mark msw runtime handlers { once: true } so
  they do not leak across cases.
- content-type-builder: jest testEnvironment back to jsdom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(tests): split jest env patches into modules

Refactor packages/admin-test-utils/src/environment.ts by extracting
the msw v2 + undici body + jsdom realm shims into dedicated modules
under src/patches/ (node-globals, request-body-stash,
response-json-realm, types).

Co-Authored-By: Jamie Howard <jhoward1994@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:45:04 +00:00

449 lines
11 KiB
TypeScript

import { errors } from '@strapi/utils';
import { type HttpHandler, http, HttpResponse } from 'msw';
import { COLLECTION_TYPES, SINGLE_TYPES } from '../src/constants/collections';
import { historyHandlers } from '../src/history/tests/server';
import { mockData } from './mockData';
export const handlers: HttpHandler[] = [
/**
*
* CONTENT_MANAGER
*
*/
http.put('/content-manager/content-types/:model/configuration', () => {
return new HttpResponse();
}),
http.get<{ model: string }>(
'/content-manager/content-types/:model/configuration',
({ params }) => {
const configuration =
params.model === 'api::homepage.homepage'
? mockData.contentManager.singleTypeConfiguration
: mockData.contentManager.collectionTypeConfiguration;
return HttpResponse.json({
data: configuration,
});
}
),
http.get<{ collectionType: string; uid: string; id: string }>(
'/content-manager/:collectionType/:uid/:id',
({ params }) => {
const { id, collectionType, uid } = params;
if (id === 'configuration') {
return;
}
if (
id === '12345' &&
collectionType === COLLECTION_TYPES &&
uid === mockData.contentManager.contentType
) {
return HttpResponse.json({
data: {
documentId: id,
id: 1,
name: 'Entry 1',
createdAt: '',
updatedAt: '',
publishedAt: '',
},
});
} else {
return HttpResponse.json(
{ error: new errors.NotFoundError('Document not found') },
{ status: 404 }
);
}
}
),
http.put<{ collectionType: string; uid: string; id: string }, Record<string, unknown>>(
'/content-manager/:collectionType/:uid/:id',
async ({ request, params }) => {
const { id, collectionType, uid } = params;
const data = await request.json();
if (
id === '12345' &&
collectionType === COLLECTION_TYPES &&
uid === mockData.contentManager.contentType
) {
return HttpResponse.json({
data: {
documentId: id,
id: 1,
name: 'Entry 1',
...data,
createdAt: '',
updatedAt: '',
publishedAt: '',
},
});
}
return HttpResponse.json(
{
error: new errors.NotFoundError('Document not found'),
},
{ status: 404 }
);
}
),
http.post<{ collectionType: string; uid: string }, Record<string, unknown>>(
'/content-manager/:collectionType/:uid',
async ({ request, params }) => {
const data = await request.json();
if (params.collectionType !== SINGLE_TYPES && params.collectionType !== COLLECTION_TYPES) {
return;
}
return HttpResponse.json({
data: {
documentId: '12345',
id: 1,
...data,
},
});
}
),
http.post<{ collectionType: string; uid: string; id: string }, Record<string, unknown>>(
'/content-manager/:collectionType/:uid/clone/:id',
async ({ request }) => {
const data = await request.json();
return HttpResponse.json({
data: {
documentId: '67890',
id: 2,
...data,
},
});
}
),
http.post<{ collectionType: string; uid: string; id: string }>(
'/content-manager/:collectionType/:uid/:id/actions/discard',
({ params }) => {
const { id } = params;
if (id === '12345') {
return HttpResponse.json({
documentId: id,
id: 1,
title: 'test',
});
}
return HttpResponse.json(
{
error: new errors.NotFoundError('Document not found'),
},
{ status: 404 }
);
}
),
http.post<{ collectionType: string; uid: string; id: string }>(
'/content-manager/:collectionType/:uid/:id/actions/publish',
({ params }) => {
const { id } = params;
if (id === '12345') {
return HttpResponse.json({
documentId: id,
id: 1,
title: 'test',
publishedAt: '2024-01-23T16:23:38.948Z',
});
}
return HttpResponse.json(
{
error: new errors.NotFoundError('Document not found'),
},
{ status: 404 }
);
}
),
http.post<{ collectionType: string; uid: string; id: string }>(
'/content-manager/:collectionType/:uid/:id/actions/unpublish',
({ params }) => {
const { id } = params;
if (id === '12345') {
return HttpResponse.json({
documentId: id,
id: 1,
title: 'test',
publishedAt: null,
});
}
return HttpResponse.json(
{
error: new errors.NotFoundError('Document not found'),
},
{ status: 404 }
);
}
),
http.post<{ uid: string }, { documentIds: string[] }>(
'/content-manager/collection-types/:uid/actions/bulkUnpublish',
async ({ request }) => {
const { documentIds } = await request.json();
if (documentIds.length === 2) {
return HttpResponse.json({
count: 2,
});
}
return HttpResponse.json(
{
error: new errors.NotFoundError('Document not found'),
},
{ status: 404 }
);
}
),
http.delete<{ collectionType: string; uid: string; id: string }>(
'/content-manager/:collectionType/:uid/:id',
({ params }) => {
const { id } = params;
if (id === '12345') {
return HttpResponse.json({
documentId: id,
id: 1,
title: 'test',
});
}
return HttpResponse.json(
{
error: new errors.NotFoundError('Document not found'),
},
{ status: 404 }
);
}
),
http.post<{ uid: string }, { documentIds: string[] }>(
'/content-manager/collection-types/:uid/actions/bulkDelete',
async ({ request }) => {
const { documentIds } = await request.json();
if (documentIds.length === 2) {
return HttpResponse.json({
count: 2,
});
}
return HttpResponse.json(
{
error: new errors.NotFoundError('Document not found'),
},
{ status: 404 }
);
}
),
http.get('/content-manager/init', () => {
return HttpResponse.json({
data: {
components: mockData.contentManager.components,
contentTypes: mockData.contentManager.contentTypes,
},
});
}),
http.post<Record<string, never>, { data?: { target?: string } }>(
'/content-manager/uid/generate',
async ({ request }) => {
const body = await request.json();
return HttpResponse.json({
data: body?.data?.target ?? 'regenerated',
});
}
),
http.post<Record<string, never>, { value?: string }>(
'/content-manager/uid/check-availability',
async ({ request }) => {
const body = await request.json();
return HttpResponse.json({
isAvailable: body?.value === 'not-taken',
});
}
),
http.get('/content-manager/collection-types/:contentType', () => {
return HttpResponse.json({
results: [
{
documentId: '12345',
id: 1,
name: 'Entry 1',
publishedAt: null,
notrepeat_req: {},
},
{
documentId: '67890',
id: 2,
name: 'Entry 2',
publishedAt: null,
notrepeat_req: {},
},
{
documentId: 'abcde',
id: 3,
name: 'Entry 3',
publishedAt: null,
notrepeat_req: {},
},
],
});
}),
http.get('/content-manager/content-types', () =>
HttpResponse.json({
data: [
{
uid: 'admin::collectionType',
isDisplayed: true,
apiID: 'permission',
kind: 'collectionType',
},
{
uid: 'admin::collectionTypeNotDispalyed',
isDisplayed: false,
apiID: 'permission',
kind: 'collectionType',
},
{
uid: 'admin::singleType',
isDisplayed: true,
kind: 'singleType',
},
{
uid: 'admin::singleTypeNotDispalyed',
isDisplayed: false,
kind: 'singleType',
},
],
})
),
http.get('/content-manager/components', () =>
HttpResponse.json({
data: [
{
uid: 'basic.relation',
isDisplayed: true,
apiID: 'relation',
category: 'basic',
info: {
displayName: 'Relation',
},
options: {},
attributes: {
id: {
type: 'integer',
},
categories: {
type: 'relation',
relation: 'oneToMany',
target: 'api::category.category',
targetModel: 'api::category.category',
relationType: 'oneToMany',
},
},
},
],
})
),
http.post('/content-manager/collection-types/:contentType/actions/bulkPublish', () => {
return HttpResponse.json({
data: {
count: 3,
},
});
}),
http.get(
'/content-manager/collection-types/:contentType/actions/countManyEntriesDraftRelations',
() => {
return HttpResponse.json({
data: 0,
});
}
),
http.get('/content-manager/relations/:model/:id/:fieldName', () => {
return HttpResponse.json({
results: [
{
id: 1,
documentId: 'apples',
locale: 'en',
status: 'draft',
name: 'Relation entity 1',
},
{
id: 2,
documentId: 'bananas',
locale: 'en',
status: 'published',
name: 'Relation entity 2',
},
{
id: 3,
documentId: 'pears',
locale: 'en',
status: 'modified',
name: 'Relation entity 3',
},
],
pagination: {
page: 1,
pageCount: 1,
total: 3,
},
});
}),
http.get('/content-manager/relations/:model/:fieldName', () => {
return HttpResponse.json({
results: [
{
id: 1,
documentId: 'apples',
locale: 'en',
status: 'draft',
name: 'Relation entity 1',
},
{
id: 2,
documentId: 'bananas',
locale: 'en',
status: 'published',
name: 'Relation entity 2',
},
{
id: 3,
documentId: 'pears',
locale: 'en',
status: 'modified',
name: 'Relation entity 3',
},
],
pagination: {
page: 1,
pageCount: 1,
total: 3,
},
});
}),
/**
* Content History
*/
...historyHandlers,
];