Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions core/common/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ build/
.vscode
package-lock.json
__pycache__
coverage/
26 changes: 26 additions & 0 deletions core/common/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

module.exports = {
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: 'tsconfig.json',
},
],
},
clearMocks: true,
testMatch: ['<rootDir>/test/**/*.ts', '<rootDir>/system-test/*.ts'],
};
15 changes: 5 additions & 10 deletions core/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@
],
"scripts": {
"docs": "jsdoc -c .jsdoc.js",
"test": "c8 mocha build/test",
"test": "jest test --runInBand --forceExit",
"prepare": "npm run compile",
"pretest": "npm run compile",
"compile": "tsc -p .",
"fix": "gts fix",
"lint": "gts check",
"presystem-test": "npm run compile",
"system-test": "mocha build/system-test",
"system-test": "jest system-test --runInBand --forceExit",
"samples-test": "cd samples/ && npm link ../ && npm test && cd ../",
"prelint": "cd samples; npm link ../; npm install",
"clean": "gts clean",
Expand All @@ -47,27 +47,22 @@
"devDependencies": {
"@types/ent": "^2.2.8",
"@types/extend": "^3.0.4",
"@types/mocha": "^10.0.10",
"@types/jest": "^29.5.12",
"@types/mv": "^2.1.4",
"@types/ncp": "^2.0.8",
"@types/node": "^22.13.5",
"@types/proxyquire": "^1.3.31",
"@types/request": "^2.48.12",
"@types/sinon": "^17.0.4",
"@types/tmp": "^0.2.6",
"c8": "^10.1.3",
"codecov": "^3.8.3",
"gts": "^6.0.2",
"jest": "^29.7.0",
"jsdoc": "^4.0.4",
"jsdoc-fresh": "^3.0.0",
"jsdoc-region-tag": "^3.0.0",
"mocha": "^11.1.0",
"mv": "^2.1.1",
"ncp": "^2.0.0",
"nock": "^14.0.1",
"proxyquire": "^2.1.3",
"sinon": "^19.0.2",
"tmp": "^0.2.3",
"ts-jest": "^29.1.2",
"typescript": "^5.8.2"
},
"homepage": "https://github.com/googleapis/google-cloud-node/tree/main/core/common"
Expand Down
244 changes: 217 additions & 27 deletions core/common/system-test/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,51 +12,229 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import {before, describe, it} from 'mocha';
import * as assert from 'assert';
import * as http from 'http';

jest.mock('gaxios', () => {
const gaxiosRequest = async (opts: any) => {
const url = opts.url || opts.uri;
const method = opts.method || 'GET';
const headers = opts.headers || {};
let body = opts.data || opts.body;

if (body && typeof body === 'object' && !(body instanceof ArrayBuffer || body instanceof Blob)) {
if (body.toString() === '[object URLSearchParams]') {
body = body.toString();
} else {
body = JSON.stringify(body);
}
}

const res = await globalThis.fetch(url, {
method,
headers,
body: method !== 'GET' && method !== 'HEAD' ? body : undefined,
});

const contentType = res.headers.get('content-type') || '';
let data;
if (contentType.includes('application/json')) {
try {
data = await res.json();
} catch {
data = await res.text();
}
} else {
data = await res.text();
}

const resHeaders: Record<string, string> = {};
res.headers.forEach((val, key) => {
resHeaders[key] = val;
});

return {
data,
status: res.status,
statusText: res.statusText,
headers: resHeaders,
config: opts,
};
};

class HttpInterceptorManager {
handlers: any[] = [];
add(interceptor: any) {
this.handlers.push(interceptor);
return this.handlers.length - 1;
}
eject(id: number) {
this.handlers[id] = null;
}
}

class Gaxios {
interceptors = {
request: new HttpInterceptorManager(),
response: new HttpInterceptorManager(),
};
static mergeHeaders(first: any, second: any) {
const merged: Record<string, string> = {};
const addHeader = (key: string, val: any) => {
merged[key.toLowerCase()] = val;
};
const process = (hdrs: any) => {
if (!hdrs) return;
if (hdrs instanceof globalThis.Headers) {
hdrs.forEach((val, key) => addHeader(key, val));
} else if (typeof hdrs === 'object') {
for (const [key, val] of Object.entries(hdrs)) {
addHeader(key, val);
}
}
};
process(first);
process(second);
return merged;
}
async request(opts: any) {
let currentOpts = { ...opts };
for (const handler of this.interceptors.request.handlers) {
if (handler && handler.fulfilled) {
currentOpts = await handler.fulfilled(currentOpts);
}
}
let res = await gaxiosRequest(currentOpts);
for (const handler of this.interceptors.response.handlers) {
if (handler && handler.fulfilled) {
res = await handler.fulfilled(res);
}
}
return res;
}
}

return {
Gaxios,
request: gaxiosRequest,
gaxios: new Gaxios(),
};
});

jest.mock('teeny-request', () => {
const teenyRequest = (opts: any, callback?: any) => {
let url = opts.uri || opts.url;
if (opts.qs) {
const searchParams = new URLSearchParams(opts.qs);
url += '?' + searchParams.toString();
}

const method = opts.method || 'GET';
const headers = opts.headers || {};
let body = opts.body || opts.json;

if (opts.json && typeof opts.json === 'object') {
headers['content-type'] = headers['content-type'] || 'application/json';
body = JSON.stringify(opts.json);
}

globalThis.fetch(url, {
method,
headers,
body: method !== 'GET' && method !== 'HEAD' ? body : undefined,
})
.then(async res => {
const contentType = res.headers.get('content-type') || '';
let body;
if (contentType.includes('application/json')) {
try {
body = await res.json();
} catch {
body = await res.text();
}
} else {
body = await res.text();
}

const resHeaders: Record<string, string> = {};
res.headers.forEach((val, key) => {
resHeaders[key] = val;
});

const response = {
statusCode: res.status,
headers: resHeaders,
body,
request: { uri: url },
};

if (res.status >= 200 && res.status < 300) {
callback(null, response, body);
} else {
const err = new Error(res.statusText) as any;
err.code = res.status;
callback(err, response, body);
}
})
.catch(err => {
callback(err);
});
};

teenyRequest.defaults = () => teenyRequest;

return {
teenyRequest,
};
});

import * as common from '../src';

describe('Common', () => {
const MOCK_HOST_PORT = 8118;
const MOCK_HOST = `http://localhost:${MOCK_HOST_PORT}`;

describe('Service', () => {
let service: common.Service;

before(() => {
service = new common.Service({
baseUrl: MOCK_HOST,
apiEndpoint: MOCK_HOST,
function createService(port: number) {
const host = `http://localhost:${port}`;
return new common.Service({
baseUrl: host,
apiEndpoint: host,
scopes: [],
packageJson: {name: 'tests', version: '1.0.0'},
}, {
projectId: 'fake-project',
});
});
}

it('should send a request and receive a response', done => {
const port = 8118;
const service = createService(port);
const mockResponse = 'response';
const mockServer = new http.Server((req, res) => {
res.end(mockResponse);
});

mockServer.listen(MOCK_HOST_PORT);
mockServer.listen(port);

service.request(
{
uri: '/mock-endpoint',
},
(err, resp) => {
assert.ifError(err);
assert.strictEqual(resp, mockResponse);
mockServer.close(done);
try {
expect(err).toBeNull();
expect(resp).toBe(mockResponse);
mockServer.close(done);
} catch (e) {
mockServer.close(() => done(e));
}
},
);
});

it('should retry a request', function (done) {
this.timeout(60 * 1000);

const port = 8119;
const service = createService(port);
let numRequestAttempts = 0;

const mockServer = new http.Server((req, res) => {
Expand All @@ -65,23 +243,27 @@ describe('Common', () => {
res.end();
});

mockServer.listen(MOCK_HOST_PORT);
mockServer.listen(port);

service.request(
{
uri: '/mock-endpoint-retry',
},
err => {
assert.strictEqual((err! as common.ApiError).code, 408);
assert.strictEqual(numRequestAttempts, 4);
mockServer.close(done);
try {
expect((err! as common.ApiError).code).toBe(408);
expect(numRequestAttempts).toBe(4);
mockServer.close(done);
} catch (e) {
mockServer.close(() => done(e));
}
},
);
});
}, 60000);

it('should retry non-responsive hosts', function (done) {
this.timeout(60 * 1000);

const port = 8120;
const service = createService(port);
function getMinimumRetryDelay(retryNumber: number) {
return Math.pow(2, retryNumber) * 1000;
}
Expand All @@ -100,12 +282,20 @@ describe('Common', () => {
uri: '/mock-endpoint-no-response',
},
err => {
assert(err?.message.includes('ECONNREFUSED'));
const timeResponse = Date.now();
assert(timeResponse - timeRequest > minExpectedResponseTime);
done();
try {
const errCode = (err as any)?.code || (err as any)?.errno || (err as any)?.cause?.code;
expect(
errCode === 'ECONNREFUSED' ||
err?.message.includes('ECONNREFUSED')
).toBeTruthy();
const timeResponse = Date.now();
expect(timeResponse - timeRequest > minExpectedResponseTime).toBeTruthy();
done();
} catch (e) {
done(e);
}
},
);
});
}, 60000);
});
});
Loading
Loading