Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8cfe5f6
this works I don't want to lose it
TkDodo Nov 7, 2025
2edcb71
use cross-fetch polyfill
TkDodo Nov 7, 2025
22da920
re-enable fetchMocks (doesn't hurt)
TkDodo Nov 7, 2025
1d87d02
fix: allow un-mocking the global API client
TkDodo Nov 7, 2025
bd73526
rewrite remaining tests to msw
TkDodo Nov 7, 2025
d91c397
fix: initApiClientErrorHandling and hasProjectBeenRenamed
TkDodo Nov 7, 2025
477ef7f
merge branch master
TkDodo Nov 8, 2025
a5f85ab
fix: mock requests to release-registry.services.sentry.io
TkDodo Nov 8, 2025
269aeb9
ref: remove unnecessary fallback
TkDodo Nov 8, 2025
90f1355
fix: rewrite groupingStore.spec to USE_REAL_API
TkDodo Nov 8, 2025
62d2e65
fix: passthrough for requests going to sentry ingest
TkDodo Nov 8, 2025
41df7b7
oops this should be beforeEach
TkDodo Nov 8, 2025
e4511dd
ok try the warn strategy
TkDodo Nov 8, 2025
7604cf3
fix: maybe like this?
TkDodo Nov 8, 2025
5771b52
fix: listen to response:mocked
TkDodo Nov 8, 2025
2c7a68b
test: fake timers
TkDodo Nov 8, 2025
46f5b6e
test: fake timers (another one)
TkDodo Nov 8, 2025
d4a6823
test: just to see if that is responsible for the timing differences
TkDodo Nov 8, 2025
42a5248
Merge branch 'master' into tkdodo/ref/msw-poc
TkDodo Nov 8, 2025
7569da0
Merge branch 'master' into tkdodo/ref/msw-poc
TkDodo Nov 10, 2025
647e4b2
test: bisect timing increase in CI
TkDodo Nov 10, 2025
a728281
bring back passthrough
TkDodo Nov 10, 2025
433e2b2
revert jest config
TkDodo Nov 10, 2025
be611f7
okay let's try with bypass
TkDodo Nov 10, 2025
99f29fe
test custom handler
TkDodo Nov 10, 2025
878a150
go back to the real thing
TkDodo Nov 10, 2025
b6986a8
Merge branch 'master' into tkdodo/ref/msw-poc
TkDodo Nov 10, 2025
ffe0137
ref: switch the only jest-fetch-mock test to msw
TkDodo Nov 10, 2025
ac41128
Merge branch 'master' into tkdodo/ref/msw-poc
TkDodo Nov 11, 2025
7f1c672
feat: localUrl helper
TkDodo Nov 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ if (
* node_modules, but some packages which use ES6 syntax only NEED to be
* transformed.
*/
const ESM_NODE_MODULES = ['screenfull', 'cbor2', 'nuqs', 'color'];
const ESM_NODE_MODULES = ['screenfull', 'cbor2', 'nuqs', 'color', 'until-async'];

const config: Config.InitialOptions = {
verbose: false,
Expand Down Expand Up @@ -292,6 +292,7 @@ const config: Config.InitialOptions = {
setupFilesAfterEnv: [
'<rootDir>/tests/js/setup.ts',
'<rootDir>/tests/js/setupFramework.ts',
'<rootDir>/tests/js/setupMsw.ts',
],
testMatch: testMatch || ['<rootDir>/(static|tests/js)/**/?(*.)+(spec|test).[jt]s?(x)'],
testPathIgnorePatterns: ['<rootDir>/tests/sentry/lang/javascript/'],
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@
"idb-keyval": "6.2.2",
"invariant": "^2.2.4",
"jed": "^1.1.0",
"jest-fetch-mock": "^3.0.3",
"js-beautify": "^1.15.1",
"js-cookie": "3.0.5",
"jsonrepair": "^3.8.0",
Expand Down Expand Up @@ -199,6 +198,7 @@
"@types/gettext-parser": "8.0.0",
"@types/node": "^22.9.1",
"babel-jest": "30.0.4",
"cross-fetch": "^4.1.0",
"eslint": "9.34.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-typescript": "^3.8.3",
Expand All @@ -223,6 +223,7 @@
"jest-fail-on-console": "3.3.1",
"jest-junit": "16.0.0",
"knip": "5.64.0",
"msw": "^2.12.0",
"postcss-styled-syntax": "0.7.0",
"react-refresh": "0.18.0",
"stylelint": "16.10.0",
Expand Down
272 changes: 251 additions & 21 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

324 changes: 31 additions & 293 deletions static/app/__mocks__/api.tsx
Original file line number Diff line number Diff line change
@@ -1,313 +1,51 @@
import isEqual from 'lodash/isEqual';
import {Client as MockClient} from './mockApi';

import type * as ApiNamespace from 'sentry/api';
import RequestError from 'sentry/utils/requestError/requestError';

const RealApi: typeof ApiNamespace = jest.requireActual('sentry/api');
const RealApi = jest.requireActual('sentry/api');
const RealClient = RealApi.Client;

export const initApiClientErrorHandling = RealApi.initApiClientErrorHandling;
export const hasProjectBeenRenamed = RealApi.hasProjectBeenRenamed;

const respond = (
asyncDelay: AsyncDelay,
fn: FunctionCallback | undefined,
...args: any[]
): void => {
if (!fn) {
return;
}

if (asyncDelay !== undefined) {
setTimeout(() => fn(...args), asyncDelay);
return;
}

fn(...args);
};

type FunctionCallback<Args extends any[] = any[]> = (...args: Args) => void;
export class Client extends MockClient {
private realClient?: InstanceType<typeof RealClient>;

/**
* Callables for matching requests based on arbitrary conditions.
*/
type MatchCallable = (url: string, options: ApiNamespace.RequestOptions) => boolean;

type AsyncDelay = undefined | number;
interface ResponseType extends ApiNamespace.ResponseMeta {
body: any;
callCount: 0;
headers: Record<string, string>;
host: string;
match: MatchCallable[];
method: string;
statusCode: number;
url: string;
/**
* Whether to return mocked api responses directly, or with a setTimeout delay.
*
* Set to `null` to disable the async delay
* Set to a `number` which will be the amount of time (ms) for the delay
*
* This will override `MockApiClient.asyncDelay` for this request.
*/
asyncDelay?: AsyncDelay;
query?: Record<string, string | number | boolean | string[] | number[]>;
}

type MockResponse = [resp: ResponseType, mock: jest.Mock];

/**
* Compare two records. `want` is all the entries we want to have the same value in `check`
*/
function compareRecord(want: Record<string, any>, check: Record<string, any>): boolean {
for (const entry of Object.entries(want)) {
const [key, value] = entry;
if (!isEqual(check[key], value)) {
return false;
}
constructor(...args: ConstructorParameters<typeof MockClient>) {
super(...args);
// Initialize real client for when __USE_REAL_API__ is true
this.realClient = new RealClient(
...(args as ConstructorParameters<typeof RealClient>)
);
}
return true;
}

afterEach(() => {
// if any errors are caught we console.error them
const errors = Object.values(Client.errors);
if (errors.length > 0) {
for (const err of errors) {
// eslint-disable-next-line no-console
console.error(err);
clear(): void {
if (globalThis.__USE_REAL_API__) {
return this.realClient?.clear();
}
Client.errors = {};
}

// Mock responses are removed between tests
Client.clearMockResponses();
});

class Client implements ApiNamespace.Client {
activeRequests: Record<string, ApiNamespace.Request> = {};
baseUrl = '';
// uses the default client json headers. Sadly, we cannot refernce the real client
// because it will cause a circular dependency and explode, hence the copy/paste
headers = {
Accept: 'application/json; charset=utf-8',
'Content-Type': 'application/json',
};

static mockResponses: MockResponse[] = [];

/**
* Whether to return mocked api responses directly, or with a setTimeout delay.
*
* Set to `null` to disable the async delay
* Set to a `number` which will be the amount of time (ms) for the delay
*
* This is the global/default value. `addMockResponse` can override per request.
*/
static asyncDelay: AsyncDelay = undefined;

static clearMockResponses() {
Client.mockResponses = [];
}

/**
* Create a query string match callable.
*
* Only keys/values defined in `query` are checked.
*/
static matchQuery(query: Record<string, any>): MatchCallable {
const queryMatcher: MatchCallable = (_url, options) => {
return compareRecord(query, options.query ?? {});
};

return queryMatcher;
}

/**
* Create a data match callable.
*
* Only keys/values defined in `data` are checked.
*/
static matchData(data: Record<string, any>): MatchCallable {
const dataMatcher: MatchCallable = (_url, options) => {
return compareRecord(data, options.data ?? {});
};

return dataMatcher;
}

// Returns a jest mock that represents Client.request calls
static addMockResponse(response: Partial<ResponseType>) {
const mock = jest.fn();

Client.mockResponses.unshift([
{
host: '',
url: '',
status: 200,
statusCode: 200,
statusText: 'OK',
responseText: '',
responseJSON: '',
body: '',
method: 'GET',
callCount: 0,
match: [],
...response,
asyncDelay: response.asyncDelay ?? Client.asyncDelay,
headers: response.headers ?? {},
getResponseHeader: (key: string) => response.headers?.[key] ?? null,
},
mock,
]);

return mock;
}

static findMockResponse(url: string, options: Readonly<ApiNamespace.RequestOptions>) {
return Client.mockResponses.find(([response]) => {
if (response.host && (options.host || '') !== response.host) {
return false;
}
if (url !== response.url) {
return false;
}
if ((options.method || 'GET') !== response.method) {
return false;
}
return response.match.every(matcher => matcher(url, options));
});
}

uniqueId() {
return '123';
}

/**
* In the real client, this clears in-flight responses. It's NOT
* clearMockResponses. You probably don't want to call this from a test.
*/
clear() {
Object.values(this.activeRequests).forEach(r => r.cancel());
return super.clear();
}

wrapCallback<T extends any[]>(
_id: string,
func: FunctionCallback<T> | undefined,
_cleanup = false
id: string,
func: ((...args: T) => void) | undefined,
cleanup = false
) {
const asyncDelay = Client.asyncDelay;

return (...args: T) => {
if ((RealApi.hasProjectBeenRenamed as any)(...args)) {
return;
}
respond(asyncDelay, func, ...args);
};
if (globalThis.__USE_REAL_API__) {
return this.realClient?.wrapCallback(id, func, cleanup);
}
return super.wrapCallback(id, func, cleanup);
}

requestPromise(
path: string,
{
includeAllArgs,
...options
}: {includeAllArgs?: boolean} & Readonly<ApiNamespace.RequestOptions> = {}
): any {
return new Promise((resolve, reject) => {
this.request(path, {
...options,
success: (data, ...args) => {
resolve(includeAllArgs ? [data, ...args] : data);
},
error: (error, ..._args) => {
reject(error);
},
});
});
requestPromise(path: string, options?: any): Promise<any> {
if (globalThis.__USE_REAL_API__) {
return this.realClient?.requestPromise(path, options);
}
return super.requestPromise(path, options);
}

static errors: Record<string, Error> = {};

// XXX(ts): We type the return type for requestPromise and request as `any`. Typically these woul
request(url: string, options: Readonly<ApiNamespace.RequestOptions> = {}): any {
const [response, mock] = Client.findMockResponse(url, options) || [
undefined,
undefined,
];
if (!response || !mock) {
const methodAndUrl = `${options.method || 'GET'} ${url}`;
// Endpoints need to be mocked
const err = new Error(`No mocked response found for request: ${methodAndUrl}`);

// Mutate stack to drop frames since test file so that we know where in the test
// this needs to be mocked
const lines = err.stack?.split('\n');
const startIndex = lines?.findIndex(line => line.includes('.spec.'));
err.stack = ['\n', lines?.[0], ...(lines?.slice(startIndex) ?? [])].join('\n');

// Throwing an error here does not do what we want it to do....
// Because we are mocking an API client, we generally catch errors to show
// user-friendly error messages, this means in tests this error gets gobbled
// up and developer frustration ensues.
// We track the errors on a static member and warn afterEach test.
Client.errors[methodAndUrl] = err;
} else {
// has mocked response

// mock gets returned when we add a mock response, will represent calls to api.request
mock(url, options);

const body =
typeof response.body === 'function' ? response.body(url, options) : response.body;

if (response.statusCode >= 300) {
response.callCount++;

const errorResponse = Object.assign(
new RequestError(options.method || 'GET', url, new Error('Request failed')),
{
status: response.statusCode,
responseText: JSON.stringify(body),
responseJSON: body,
},
{
overrideMimeType: () => {},
abort: () => {},
then: () => {},
error: () => {},
}
);

this.handleRequestError(
{
id: '1234',
path: url,
requestOptions: options,
},
errorResponse as any,
'error',
'error'
);
} else {
response.callCount++;
respond(
response.asyncDelay,
options.success,
body,
{},
{
getResponseHeader: (key: string) => response.headers[key],
statusCode: response.statusCode,
status: response.statusCode,
}
);
}
request(url: string, options?: any): any {
if (globalThis.__USE_REAL_API__) {
return this.realClient?.request(url, options);
}

respond(response?.asyncDelay, options.complete);
return super.request(url, options);
}

handleRequestError = RealApi.Client.prototype.handleRequestError;
}

export {Client};
Loading
Loading