mirror of
https://github.com/strapi/strapi.git
synced 2026-06-02 16:27:47 +00:00
* 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>
449 lines
11 KiB
TypeScript
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,
|
|
];
|