Skip to content

Commit f2a2670

Browse files
authored
feat: common test suite generation APIC-186 (#22)
1 parent ea1aef7 commit f2a2670

File tree

16 files changed

+3517
-105
lines changed

16 files changed

+3517
-105
lines changed

.eslintrc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ module.exports = {
3030
'no-bitwise': 0,
3131
'@typescript-eslint/no-namespace': 0,
3232
'max-classes-per-file': 0,
33-
'no-unused-vars': 0,
33+
'no-continue': 0,
3434
'@typescript-eslint/prefer-enum-initializers': 0,
3535
// there's a conflict when declaring `type` and `namespaces`, even with `ignoreDeclarationMerge`
3636
'no-redeclare': 0,

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ yarn-error.log
1515
**/dist
1616
**/.openapi-generator-ignore
1717
**/git_push.sh
18+
19+
.vscode

clients/algoliasearch-client-javascript/client-search/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"yarn": "^3.0.0"
1919
},
2020
"devDependencies": {
21-
"@types/node": "^16.11.6",
21+
"@types/node": "16.11.11",
2222
"typescript": "4.5.2"
2323
}
2424
}

clients/algoliasearch-client-javascript/recommend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"yarn": "^3.0.0"
1919
},
2020
"devDependencies": {
21-
"@types/node": "^16.11.6",
21+
"@types/node": "16.11.11",
2222
"typescript": "4.5.2"
2323
}
2424
}

doc/CTS.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Common Test Suite
2+
3+
The CTS aims at ensuring minimal working operation for the API clients, by comparing the request formed by sample parameters.
4+
It is automaticaly generated for all languages, from a JSON entry point.
5+
6+
## How to run it
7+
8+
```bash
9+
yarn cts:generate
10+
yarn cts:test
11+
```
12+
13+
If you only want to generate the tests for a set of languages, you can run:
14+
15+
```bash
16+
yarn cts:generate "javascript ruby"
17+
```
18+
19+
## How to add test
20+
21+
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/`).
22+
23+
```json
24+
[
25+
{
26+
"testName": "the name of the test (e.g. test('search endpoint'))",
27+
"method": "the method to call (e.g. search)",
28+
"parameters": [
29+
"indexName",
30+
{
31+
"$objectName": "the name of the object for strongly type language",
32+
"query": "the string to search"
33+
}
34+
],
35+
"request": {
36+
"path": "/1/indexes/indexName/query",
37+
"method": "POST",
38+
"data": { "query": "the string to search" }
39+
}
40+
}
41+
]
42+
```
43+
44+
And that's it! If the name of the file matches a real `operationId` in the spec, then a test will be generated.
45+
46+
## How to add a new language
47+
48+
- Create a template in `test/CTS/templates/<your language>.mustache` that parse a array of test into your test framework of choice
49+
- Add the language in the array `languages` in `tests/generateCTS.ts`.

doc/contribution_addNewClient.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ You will need to implement:
2828

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

3337
### Init method

openapitools.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"generator-cli": {
44
"version": "5.3.0",
55
"generators": {
6-
"javascript-client-search": {
6+
"javascript-search": {
77
"generatorName": "typescript-node",
88
"templateDir": "#{cwd}/templates/javascript/",
99
"config": "#{cwd}/openapitools.json",
@@ -17,6 +17,7 @@
1717
"modelPropertyNaming": "original",
1818
"supportsES6": true,
1919
"npmName": "@algolia/client-search",
20+
"packageName": "@algolia/client-search",
2021
"npmVersion": "5.0.0"
2122
}
2223
},
@@ -35,6 +36,7 @@
3536
"modelPropertyNaming": "original",
3637
"supportsES6": true,
3738
"npmName": "@algolia/recommend",
39+
"packageName": "@algolia/recommend",
3840
"npmVersion": "5.0.0"
3941
}
4042
}

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"version": "0.0.0",
44
"workspaces": [
55
"clients/algoliasearch-client-javascript/*",
6-
"playground/javascript/"
6+
"playground/javascript/",
7+
"tests/"
78
],
89
"scripts": {
910
"build:spec:recommend:json": "yarn swagger-cli bundle specs/recommend/spec.yml --outfile specs/dist/recommend.json --type json",
@@ -17,11 +18,13 @@
1718
"client:build-js:recommend": "yarn workspace @algolia/recommend build",
1819
"client:build-js": "yarn client:build-js:search && yarn client:build-js:recommend",
1920
"client:build": "yarn client:build-js",
21+
"cts:generate": "yarn workspace tests cts:generate",
22+
"cts:test": "yarn workspace tests test",
2023
"lint:client:fix": "eslint --ext=ts ${CLIENT} --fix",
2124
"lint": "eslint --ext=ts .",
2225
"format:specs": "yarn prettier --write specs",
2326
"generate:js:recommend": "yarn openapi-generator-cli generate --generator-key javascript-recommend && CLIENT=recommend yarn utils:import-js && CLIENT=clients/algoliasearch-client-javascript/recommend/ yarn lint:client:fix",
24-
"generate:js:search": "yarn openapi-generator-cli generate --generator-key javascript-client-search && CLIENT=client-search yarn utils:import-js && CLIENT=clients/algoliasearch-client-javascript/client-search/ yarn lint:client:fix",
27+
"generate:js:search": "yarn openapi-generator-cli generate --generator-key javascript-search && CLIENT=client-search yarn utils:import-js && CLIENT=clients/algoliasearch-client-javascript/client-search/ yarn lint:client:fix",
2528
"generate:js": "yarn generate:js:search && yarn generate:js:recommend",
2629
"generate:recommend": "yarn generate:js:recommend",
2730
"generate:search": "yarn generate:js:search",

templates/javascript/package.mustache

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
2-
"name": "{{npmName}}",
2+
"name": "{{packageName}}",
33
"version": "{{npmVersion}}",
4-
"description": "JavaScript client for {{npmName}}",
4+
"description": "JavaScript client for {{packageName}}",
55
"repository": "{{gitUserId}}/{{gitRepoId}}",
66
"author": "Algolia",
77
"private": true,
@@ -18,7 +18,7 @@
1818
"yarn": "^3.0.0"
1919
},
2020
"devDependencies": {
21-
"@types/node": "^16.11.6",
21+
"@types/node": "16.11.11",
2222
"typescript": "4.5.2"
2323
}{{#npmRepository}},
2424
"publishConfig": {

tests/CTS/clients/search/search.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[
2+
{
3+
"testName": "search",
4+
"method": "search",
5+
"parameters": [
6+
"indexName",
7+
{
8+
"$objectName": "Query",
9+
"query": "queryString"
10+
}
11+
],
12+
"request": {
13+
"path": "/1/indexes/indexName/query",
14+
"method": "POST",
15+
"data": {
16+
"query": "queryString"
17+
}
18+
}
19+
}
20+
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { {{client}}, EchoRequester } from '{{{import}}}';
2+
3+
describe('Common Test Suite', () => {
4+
const client = new {{client}}(process.env.ALGOLIA_APPLICATION_ID, process.env.ALGOLIA_SEARCH_KEY, { requester: new EchoRequester() });
5+
6+
{{#tests}}
7+
test('{{testName}}', async () => {
8+
const req = await client.{{method}}({{#parameters}}{{{value}}}{{^-last}}, {{/-last}}{{/parameters}});
9+
expect(req).toMatchObject({
10+
path: '{{{request.path}}}',
11+
method: '{{{request.method}}}',
12+
data: {{{request.data}}},
13+
})
14+
});
15+
16+
{{/tests}}
17+
});

tests/generateCTS.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/* eslint-disable no-console */
2+
import fsp from 'fs/promises';
3+
import path from 'path';
4+
5+
import Mustache from 'mustache';
6+
import type { OpenAPIV3 } from 'openapi-types';
7+
import SwaggerParser from 'swagger-parser';
8+
9+
import openapitools from '../openapitools.json';
10+
11+
const availableLanguages = ['javascript'] as const;
12+
type Language = typeof availableLanguages[number];
13+
14+
type CTSBlock = {
15+
name: string;
16+
method: string;
17+
parameters: any[];
18+
request: {
19+
path: string;
20+
method: string;
21+
data: string;
22+
};
23+
};
24+
25+
// Array of test per client
26+
type CTS = Record<string, CTSBlock[]>;
27+
28+
const extensionForLanguage: Record<Language, string> = {
29+
javascript: '.test.ts',
30+
};
31+
32+
const cts: CTS = {};
33+
34+
// For each generator, we map the packageName with the language and client
35+
const packageNames: Record<string, Record<Language, string>> = Object.entries(
36+
openapitools['generator-cli'].generators
37+
).reduce((prev, [clientName, clientConfig]) => {
38+
const obj = prev;
39+
const [lang, client] = clientName.split('-') as [Language, string];
40+
41+
if (!(lang in prev)) {
42+
obj[lang] = {};
43+
}
44+
45+
obj[lang][client] = clientConfig.additionalProperties.packageName;
46+
47+
return obj;
48+
}, {} as Record<string, Record<string, string>>);
49+
50+
async function createOutputDir(language: Language): Promise<void> {
51+
await fsp.mkdir(`output/${language}`, { recursive: true });
52+
}
53+
54+
async function* walk(
55+
dir: string
56+
): AsyncGenerator<{ path: string; name: string }> {
57+
for await (const d of await fsp.opendir(dir)) {
58+
const entry = path.join(dir, d.name);
59+
if (d.isDirectory()) yield* walk(entry);
60+
else if (d.isFile()) yield { path: entry, name: d.name };
61+
}
62+
}
63+
64+
function capitalize(str: string): string {
65+
return str.charAt(0).toUpperCase() + str.slice(1);
66+
}
67+
68+
async function loadCTSForClient(client: string): Promise<CTSBlock[]> {
69+
// load the list of operations from the spec
70+
const spec = await SwaggerParser.validate(`../specs/${client}/spec.yml`);
71+
const operations = Object.values(spec.paths)
72+
.flatMap<OpenAPIV3.OperationObject>((p) => Object.values(p))
73+
.map((obj) => obj.operationId);
74+
75+
const ctsClient: CTSBlock[] = [];
76+
77+
for await (const file of walk(`./CTS/clients/${client}`)) {
78+
if (!file.name.endsWith('json')) {
79+
continue;
80+
}
81+
const operationId = file.name.replace('.json', '');
82+
const tests: CTSBlock[] = JSON.parse(
83+
(await fsp.readFile(file.path)).toString()
84+
);
85+
86+
// check test validity against spec
87+
if (!operations.includes(operationId)) {
88+
throw new Error(
89+
`cannot find operationId ${operationId} for the ${client} client`
90+
);
91+
}
92+
93+
// for now we stringify all params for mustache to render them properly
94+
for (const test of tests) {
95+
for (let i = 0; i < test.parameters.length; i++) {
96+
// delete the object name for now, but it could be use for `new $objectName(params)`
97+
delete test.parameters[i].$objectName;
98+
99+
// include the `-last` param to join with comma in mustache
100+
test.parameters[i] = {
101+
value: JSON.stringify(test.parameters[i]),
102+
'-last': i === test.parameters.length - 1,
103+
};
104+
}
105+
106+
// stringify request.data too
107+
test.request.data = JSON.stringify(test.request.data);
108+
}
109+
ctsClient.push(...tests);
110+
}
111+
return ctsClient;
112+
}
113+
114+
async function loadCTS(): Promise<void> {
115+
for await (const { name: client } of await fsp.opendir('./CTS/clients/')) {
116+
cts[client] = await loadCTSForClient(client);
117+
}
118+
}
119+
120+
async function loadTemplate(language: Language): Promise<string> {
121+
return (await fsp.readFile(`CTS/templates/${language}.mustache`)).toString();
122+
}
123+
124+
async function generateCode(language: Language): Promise<void> {
125+
const template = await loadTemplate(language);
126+
await createOutputDir(language);
127+
for (const client in cts) {
128+
if (cts[client].length === 0) {
129+
continue;
130+
}
131+
132+
const code = Mustache.render(template, {
133+
import: packageNames[language][client],
134+
client: `${capitalize(client)}Api`,
135+
tests: cts[client],
136+
});
137+
await fsp.writeFile(
138+
`output/${language}/${client}${extensionForLanguage[language]}`,
139+
code
140+
);
141+
}
142+
}
143+
144+
function printUsage(): void {
145+
console.log(`usage: generateCTS all | language1 language2...`);
146+
console.log(`\tavailable languages: ${availableLanguages.join(',')}`);
147+
// eslint-disable-next-line no-process-exit
148+
process.exit(1);
149+
}
150+
151+
async function parseCLI(args: string[]): Promise<void> {
152+
if (args.length < 3) {
153+
console.log('not enough arguments');
154+
printUsage();
155+
}
156+
157+
let toGenerate: Language[];
158+
if (args.length === 3 && args[2] === 'all') {
159+
toGenerate = [...availableLanguages];
160+
} else {
161+
const languages = args[2].split(' ') as Language[];
162+
const unknownLanguages = languages.filter(
163+
(lang) => !availableLanguages.includes(lang)
164+
);
165+
if (unknownLanguages.length > 0) {
166+
console.log('unkown language(s): ', unknownLanguages.join(', '));
167+
printUsage();
168+
}
169+
toGenerate = languages;
170+
}
171+
172+
try {
173+
await loadCTS();
174+
for (const lang of toGenerate) {
175+
generateCode(lang);
176+
}
177+
} catch (e) {
178+
if (e instanceof Error) {
179+
console.error(e);
180+
}
181+
}
182+
}
183+
184+
parseCLI(process.argv);

tests/jest.config.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
require('dotenv').config();
2+
3+
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
4+
module.exports = {
5+
preset: 'ts-jest',
6+
testEnvironment: 'node',
7+
};

0 commit comments

Comments
 (0)