Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"yarn": "^3.0.0"
},
"devDependencies": {
"@types/node": "^16.11.6",
"@types/node": "16.11.11",
"typescript": "4.5.2"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"yarn": "^3.0.0"
},
"devDependencies": {
"@types/node": "^16.11.6",
"@types/node": "16.11.11",
"typescript": "4.5.2"
}
}
48 changes: 48 additions & 0 deletions doc/CTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Common Test Suite

The CTS aims at ensuring minimal working operation for the API clients, by comparing the request formed by sample parameters.
It is automaticaly generated for all languages, from a JSON entry point.

## How to run it

```bash
yarn cts:generate all
yarn cts:test
```

If you only want to generate the tests for a set of languages, you can run:
```bash
yarn cts:generate "javascript ruby"
```

## How to add test

The test generation script requires a JSON file name from the `operationId` (e.g. `search.json`), located in the `CTS/<client>/` folder (e.g. `CTS/search/`).
```json
[
{
"name": "test name",
"method": "the method to call (ex: search)",
"parameters": [
"indexName",
{
"$objectName": "the name of the object for strongly type language",
"query": "the string to search"
}
],
"request": {
"path": "/1/indexes/indexName/query",
"method": "POST",
"data": { "query": "the string to search" }
}
}
]
```

And that's it! If the name of the file matches a real `operationId` in the spec, then a test will be generated.

## How to add a new language

- Create a template in `test/CTS/templates/<your language>.mustache` that parse a array of test into your test framework of choice
- Add the language in the array `languages` in `tests/generateCTS.ts`.

4 changes: 4 additions & 0 deletions doc/contribution_addNewClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ You will need to implement:

- An `init` method
- The `retry strategy` with your custom transporter
- At least 2 requester:
- http requester, using the standard library
- echo requester that send the request back, used by the CTS
- A logger that the user can swap
- More to come...

### Init method
Expand Down
2 changes: 1 addition & 1 deletion openapitools.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"generator-cli": {
"version": "5.3.0",
"generators": {
"javascript-client-search": {
"javascript-search": {
"generatorName": "typescript-node",
"templateDir": "#{cwd}/templates/javascript/",
"config": "#{cwd}/openapitools.json",
Expand Down
13 changes: 8 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"version": "0.0.0",
"workspaces": [
"clients/algoliasearch-client-javascript/*",
"playground/javascript/"
"playground/javascript/",
"tests/"
],
"scripts": {
"build:spec:recommend:json": "yarn swagger-cli bundle specs/recommend/spec.yml --outfile specs/dist/recommend.json --type json",
Expand All @@ -17,11 +18,13 @@
"client:build-js:recommend": "yarn workspace @algolia/recommend build",
"client:build-js": "yarn client:build-js:search && yarn client:build-js:recommend",
"client:build": "yarn client:build-js",
"cts:generate": "yarn workspace tests cts:generate",
"cts:test": "yarn workspace tests test",
"lint:js": "yarn prettier --write clients/algoliasearch-client-javascript/${CLIENT}",
"lint:specs": "yarn prettier --write specs",
"lint": "yarn lint:specs && yarn lint:js",
"generate:js:recommend": "yarn openapi-generator-cli generate --generator-key javascript-recommend && CLIENT=recommend yarn utils:import-js && CLIENT=recommend yarn lint:js",
"generate:js:search": "yarn openapi-generator-cli generate --generator-key javascript-client-search && CLIENT=client-search yarn utils:import-js && CLIENT=client-search yarn lint:js",
"generate:js:search": "yarn openapi-generator-cli generate --generator-key javascript-search && CLIENT=client-search yarn utils:import-js && CLIENT=client-search yarn lint:js",
"generate:js": "yarn generate:js:search && yarn generate:js:recommend",
"generate:recommend": "yarn generate:js:recommend",
"generate:search": "yarn generate:js:search",
Expand All @@ -32,9 +35,9 @@
"validate": "yarn swagger-cli validate specs/dist/search.yml && yarn swagger-cli validate specs/dist/recommend.yml"
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "^2.4.13",
"prettier": "2.4.1",
"swagger-cli": "^4.0.4"
"@openapitools/openapi-generator-cli": "2.4.18",
"prettier": "2.5.0",
"swagger-cli": "4.0.4"
},
"engines": {
"node": "^16.0.0",
Expand Down
2 changes: 1 addition & 1 deletion playground/javascript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"@algolia/client-search": "5.0.0",
"@algolia/recommend": "5.0.0",
"dotenv": "10.0.0",
"prettier": "2.4.1",
"prettier": "2.5.0",
"typescript": "4.5.2"
},
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion templates/javascript/package.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"yarn": "^3.0.0"
},
"devDependencies": {
"@types/node": "^16.11.6",
"@types/node": "16.11.11",
"typescript": "4.5.2"
}
{{#npmRepository}},
Expand Down
20 changes: 20 additions & 0 deletions tests/CTS/clients/search/search.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[
{
"name": "search",
"method": "search",
"parameters": [
"indexName",
{
"$objectName": "Query",
"query": "queryString"
}
],
"request": {
"path": "/1/indexes/indexName/query",
"method": "POST",
"data": {
"query": "queryString"
}
}
}
]
17 changes: 17 additions & 0 deletions tests/CTS/templates/javascript.mustache
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { {{client}}, EchoRequester } from '{{{import}}}';

describe('Common Test Suite', () => {
const client = new {{client}}(process.env.ALGOLIA_APPLICATION_ID, process.env.ALGOLIA_SEARCH_KEY, { requester: new EchoRequester() });

{{#tests}}
test('{{name}}', async () => {
const req = await client.{{method}}({{#parameters}}{{{value}}}{{^-last}}, {{/-last}}{{/parameters}});
expect(req).toMatchObject({
path: '{{{request.path}}}',
method: '{{{request.method}}}',
data: {{{request.data}}},
})
});

{{/tests}}
});
169 changes: 169 additions & 0 deletions tests/generateCTS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import * as fsp from 'fs/promises';
import Mustache from 'mustache';
import * as path from 'path';
import SwaggerParser from 'swagger-parser';
import { OpenAPIV3 } from 'openapi-types';

const availableLanguages = ['javascript'] as const;
type Language = typeof availableLanguages[number];

type CTSBlock = {
name: string;
method: string;
parameters: any[];
request: {
path: string;
method: string;
data: string;
};
};

// Array of test per client
type CTS = Record<string, CTSBlock[]>;

const packageNameMapping: Record<Language, string> = { javascript: 'npmName' };
const extensionForLanguage: Record<Language, string> = { javascript: '.test.ts' };

//For each language, for each client, we have a package name
let packageNames: Record<string, Record<Language, string>> = {};
let cts: CTS = {};

async function createOutputDir(language: Language) {
await fsp.mkdir(`output/${language}`, { recursive: true });
}

async function* walk(dir: string): AsyncGenerator<{ path: string; name: string }> {
for await (const d of await fsp.opendir(dir)) {
const entry = path.join(dir, d.name);
if (d.isDirectory()) yield* walk(entry);
else if (d.isFile()) yield { path: entry, name: d.name };
}
}

function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}

async function loadPackageNames(): Promise<void> {
const openapitools = JSON.parse((await fsp.readFile('../openapitools.json')).toString());
// For each generator, we map the packageName with the language and client
packageNames = Object.entries(openapitools['generator-cli'].generators).reduce(
(prev, curr: any) => {
const [lang, client] = curr[0].split('-') as [Language, string];
if (!(lang in prev)) {
prev[lang] = {};
}
prev[lang][client] = curr[1].additionalProperties[packageNameMapping[lang]];
return prev;
},
{}
);
}

async function loadCTSForClient(client: string): Promise<CTSBlock[]> {
// load the list of operations from the spec
const spec = await SwaggerParser.validate(`../specs/${client}/spec.yml`);
const operations = Object.values(spec.paths)
.flatMap<OpenAPIV3.OperationObject>((path) => Object.values(path))
.map((obj) => obj.operationId);

const ctsClient: CTSBlock[] = [];

for await (const file of walk(`./CTS/clients/${client}`)) {
if (!file.name.endsWith('json')) {
continue;
}
const operationId = file.name.replace('.json', '');
const tests: CTSBlock[] = JSON.parse((await fsp.readFile(file.path)).toString());

// for now we stringify all params for mustache to render them properly
for (const test of tests) {
for (let i = 0; i < test.parameters.length; i++) {
// delete the object name for now, but it could be use for `new $objectName(params)`
delete test.parameters[i]['$objectName'];

// include the `-last` param to join with comma in mustache
test.parameters[i] = {
value: JSON.stringify(test.parameters[i]),
'-last': i === test.parameters.length - 1,
};
}

// stringify request.data too
test.request.data = JSON.stringify(test.request.data);
}

// check test validity against spec
if (!operations.includes(operationId)) {
throw new Error(`cannot find operationId ${operationId} for the ${client} client`);
}
ctsClient.push(...tests);
}
return ctsClient;
}

async function loadCTS(): Promise<void> {
for await (const { name: client } of await fsp.opendir('./CTS/clients/')) {
cts[client] = await loadCTSForClient(client);
}
}

async function loadTemplate(language: Language): Promise<string> {
return (await fsp.readFile(`CTS/templates/${language}.mustache`)).toString();
}

async function generateCode(language: Language) {
const template = await loadTemplate(language);
await createOutputDir(language);
for (const client in cts) {
if (cts[client].length === 0) {
continue;
}

const code = Mustache.render(template, {
import: packageNames[language][client],
client: `${capitalize(client)}Api`,
tests: cts[client],
});
await fsp.writeFile(`output/${language}/${client}${extensionForLanguage[language]}`, code);
}
}

function printUsage() {
console.log(`usage: generateCTS all | language1 language2...`);
console.log(`\tavailable languages: ${availableLanguages.join(',')}`);
process.exit(1);
}

async function parseCLI(args: string[]) {
if (args.length < 3) {
console.log('not enough arguments');
printUsage();
}

let toGenerate: Language[];
if (args.length == 3 && args[2] === 'all') {
toGenerate = [...availableLanguages];
} else {
const languages = args.slice(2).flatMap((l) => l.split(' ')) as Language[];
if (!languages.every((lang) => availableLanguages.includes(lang))) {
console.log('unkown language: ', languages.join(', '));
printUsage();
}
toGenerate = languages;
}

try {
await loadPackageNames();
await loadCTS();
for (const lang of toGenerate) {
generateCode(lang);
}
} catch (e) {
if (e instanceof Error) {
console.error(e);
}
}
}

parseCLI(process.argv);
7 changes: 7 additions & 0 deletions tests/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
require('dotenv').config();

/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
16 changes: 16 additions & 0 deletions tests/output/javascript/search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { SearchApi, EchoRequester } from '@algolia/client-search';

describe('Common Test Suite', () => {
const client = new SearchApi(process.env.ALGOLIA_APPLICATION_ID, process.env.ALGOLIA_SEARCH_KEY, {
requester: new EchoRequester(),
});

test('search', async () => {
const req = await client.search('indexName', { query: 'queryString' });
expect(req).toMatchObject({
path: '/1/indexes/indexName/query',
method: 'POST',
data: { query: 'queryString' },
});
});
});
Loading