test: run cloud sketches tests on the CI (#2092)

- fix(test): integration tests are more resilient.
   - run the Create integration tests with other slow tests,
   - queued `PUT`/`DELETE` requests to make the test timeout happy,
   - reduced the `/sketches/search` offset to 1/5th and
   - remove Create API logging.
 - fix(linter): ignore `lib` folder. Remove obsolete `.node_modules`
pattern.
 - feat(ci): enable Create API integration tests.

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
Co-authored-by: per1234 <accounts@perglass.com>
This commit is contained in:
Akos Kitta 2023-10-07 10:38:54 +02:00 committed by GitHub
parent 3f4d2745a8
commit 0b2410d49a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 94 additions and 37 deletions

View File

@ -10,7 +10,6 @@ module.exports = {
ignorePatterns: [ ignorePatterns: [
'node_modules/*', 'node_modules/*',
'**/node_modules/*', '**/node_modules/*',
'.node_modules/*',
'.github/*', '.github/*',
'.browser_modules/*', '.browser_modules/*',
'docs/*', 'docs/*',
@ -21,6 +20,7 @@ module.exports = {
'!electron-app/webpack.config.js', '!electron-app/webpack.config.js',
'plugins/*', 'plugins/*',
'arduino-ide-extension/src/node/cli-protocol', 'arduino-ide-extension/src/node/cli-protocol',
'**/lib/*',
], ],
settings: { settings: {
react: { react: {

View File

@ -296,6 +296,11 @@ jobs:
IS_NIGHTLY: ${{ needs.build-type-determination.outputs.is-nightly }} IS_NIGHTLY: ${{ needs.build-type-determination.outputs.is-nightly }}
IS_RELEASE: ${{ needs.build-type-determination.outputs.is-release }} IS_RELEASE: ${{ needs.build-type-determination.outputs.is-release }}
CAN_SIGN: ${{ secrets[matrix.config.certificate-secret] != '' }} CAN_SIGN: ${{ secrets[matrix.config.certificate-secret] != '' }}
# The CREATE_* environment vars are only used to run tests. These secrets are optional. Dependent tests will
# be skipped if not available.
CREATE_USERNAME: ${{ secrets.CREATE_USERNAME }}
CREATE_PASSWORD: ${{ secrets.CREATE_PASSWORD }}
CREATE_CLIENT_SECRET: ${{ secrets.CREATE_CLIENT_SECRET }}
run: | run: |
# See: https://www.electron.build/code-signing # See: https://www.electron.build/code-signing
if [ $CAN_SIGN = false ]; then if [ $CAN_SIGN = false ]; then

View File

@ -179,7 +179,8 @@ export class CreateApi {
); );
}) })
.catch((reason) => { .catch((reason) => {
if (reason?.status === 404) return [] as Create.Resource[]; if (reason?.status === 404)
return [] as Create.Resource[]; // TODO: must not swallow 404
else throw reason; else throw reason;
}); });
} }
@ -486,18 +487,12 @@ export class CreateApi {
await this.run(url, init, ResponseResultProvider.NOOP); await this.run(url, init, ResponseResultProvider.NOOP);
} }
private fetchCounter = 0;
private async run<T>( private async run<T>(
requestInfo: URL, requestInfo: URL,
init: RequestInit | undefined, init: RequestInit | undefined,
resultProvider: ResponseResultProvider = ResponseResultProvider.JSON resultProvider: ResponseResultProvider = ResponseResultProvider.JSON
): Promise<T> { ): Promise<T> {
const fetchCount = `[${++this.fetchCounter}]`;
const fetchStart = performance.now();
const method = init?.method ? `${init.method}: ` : '';
const url = requestInfo.toString();
const response = await fetch(requestInfo.toString(), init); const response = await fetch(requestInfo.toString(), init);
const fetchEnd = performance.now();
if (!response.ok) { if (!response.ok) {
let details: string | undefined = undefined; let details: string | undefined = undefined;
try { try {
@ -508,18 +503,7 @@ export class CreateApi {
const { statusText, status } = response; const { statusText, status } = response;
throw new CreateError(statusText, status, details); throw new CreateError(statusText, status, details);
} }
const parseStart = performance.now();
const result = await resultProvider(response); const result = await resultProvider(response);
const parseEnd = performance.now();
console.debug(
`HTTP ${fetchCount} ${method}${url} [fetch: ${(
fetchEnd - fetchStart
).toFixed(2)} ms, parse: ${(parseEnd - parseStart).toFixed(
2
)} ms] body: ${
typeof result === 'string' ? result : JSON.stringify(result)
}`
);
return result; return result;
} }

View File

@ -82,6 +82,13 @@ export function isNotFound(err: unknown): err is NotFoundError {
return isErrorWithStatusOf(err, 404); return isErrorWithStatusOf(err, 404);
} }
export type UnprocessableContentError = CreateError & { status: 422 };
export function isUnprocessableContent(
err: unknown
): err is UnprocessableContentError {
return isErrorWithStatusOf(err, 422);
}
function isErrorWithStatusOf( function isErrorWithStatusOf(
err: unknown, err: unknown,
status: number status: number

View File

@ -5,17 +5,24 @@ import {
} from '@theia/core/shared/inversify'; } from '@theia/core/shared/inversify';
import { assert, expect } from 'chai'; import { assert, expect } from 'chai';
import fetch from 'cross-fetch'; import fetch from 'cross-fetch';
import { rejects } from 'node:assert';
import { posix } from 'node:path'; import { posix } from 'node:path';
import PQueue from 'p-queue';
import queryString from 'query-string';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { ArduinoPreferences } from '../../browser/arduino-preferences'; import { ArduinoPreferences } from '../../browser/arduino-preferences';
import { AuthenticationClientService } from '../../browser/auth/authentication-client-service'; import { AuthenticationClientService } from '../../browser/auth/authentication-client-service';
import { CreateApi } from '../../browser/create/create-api'; import { CreateApi } from '../../browser/create/create-api';
import { splitSketchPath } from '../../browser/create/create-paths'; import { splitSketchPath } from '../../browser/create/create-paths';
import { Create, CreateError } from '../../browser/create/typings'; import {
Create,
CreateError,
isNotFound,
isUnprocessableContent,
} from '../../browser/create/typings';
import { SketchCache } from '../../browser/widgets/cloud-sketchbook/cloud-sketch-cache'; import { SketchCache } from '../../browser/widgets/cloud-sketchbook/cloud-sketch-cache';
import { SketchesService } from '../../common/protocol'; import { SketchesService } from '../../common/protocol';
import { AuthenticationSession } from '../../node/auth/types'; import { AuthenticationSession } from '../../node/auth/types';
import queryString from 'query-string';
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ /* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
@ -44,6 +51,11 @@ describe('create-api', () => {
await cleanAllSketches(); await cleanAllSketches();
}); });
afterEach(async function () {
this.timeout(timeout);
await cleanAllSketches();
});
function createContainer(accessToken: string): Container { function createContainer(accessToken: string): Container {
const container = new Container({ defaultScope: 'Singleton' }); const container = new Container({ defaultScope: 'Singleton' });
container.load( container.load(
@ -120,13 +132,14 @@ describe('create-api', () => {
async function cleanAllSketches(): Promise<void> { async function cleanAllSketches(): Promise<void> {
let sketches = await createApi.sketches(); let sketches = await createApi.sketches();
// Cannot delete the sketches with `await Promise.all` as all delete promise successfully resolve, but the sketch is not deleted from the server. const deleteExecutionQueue = new PQueue({
await sketches concurrency: 5,
.map(({ path }) => createApi.deleteSketch(path)) autoStart: true,
.reduce(async (acc, curr) => { });
await acc; sketches.forEach(({ path }) =>
return curr; deleteExecutionQueue.add(() => createApi.deleteSketch(path))
}, Promise.resolve()); );
await deleteExecutionQueue.onIdle();
sketches = await createApi.sketches(); sketches = await createApi.sketches();
expect(sketches).to.be.empty; expect(sketches).to.be.empty;
} }
@ -229,8 +242,52 @@ describe('create-api', () => {
expect(findByName(otherName, sketches)).to.be.not.undefined; expect(findByName(otherName, sketches)).to.be.not.undefined;
}); });
it('should error with HTTP 422 when reading a file but is a directory', async () => {
const name = v4();
const content = 'void setup(){} void loop(){}';
const posixPath = toPosix(name);
await createApi.createSketch(posixPath, content);
const resources = await createApi.readDirectory(posixPath);
expect(resources).to.be.not.empty;
await rejects(createApi.readFile(posixPath), (thrown) =>
isUnprocessableContent(thrown)
);
});
it('should error with HTTP 422 when listing a directory but is a file', async () => {
const name = v4();
const content = 'void setup(){} void loop(){}';
const posixPath = toPosix(name);
await createApi.createSketch(posixPath, content);
const mainSketchFilePath = posixPath + posixPath + '.ino';
const sketchContent = await createApi.readFile(mainSketchFilePath);
expect(sketchContent).to.be.equal(content);
await rejects(createApi.readDirectory(mainSketchFilePath), (thrown) =>
isUnprocessableContent(thrown)
);
});
it("should error with HTTP 404 when deleting a non-existing directory via the '/files/d' endpoint", async () => {
const name = v4();
const posixPath = toPosix(name);
const sketches = await createApi.sketches();
const sketch = findByName(name, sketches);
expect(sketch).to.be.undefined;
await rejects(createApi.deleteDirectory(posixPath), (thrown) =>
isNotFound(thrown)
);
});
['.', '-', '_'].map((char) => { ['.', '-', '_'].map((char) => {
it(`should create a new sketch with '${char}' in the sketch folder name although it's disallowed from the Create Editor`, async () => { it(`should create a new sketch with '${char}' (character code: ${char.charCodeAt(
0
)}) in the sketch folder name although it's disallowed from the Create Editor`, async () => {
const name = `sketch${char}`; const name = `sketch${char}`;
const posixPath = toPosix(name); const posixPath = toPosix(name);
const newSketch = await createApi.createSketch( const newSketch = await createApi.createSketch(
@ -300,19 +357,23 @@ describe('create-api', () => {
diff < 0 ? '<' : diff > 0 ? '>' : '=' diff < 0 ? '<' : diff > 0 ? '>' : '='
} limit)`, async () => { } limit)`, async () => {
const content = 'void setup(){} void loop(){}'; const content = 'void setup(){} void loop(){}';
const maxLimit = 50; // https://github.com/arduino/arduino-ide/pull/875 const maxLimit = 10;
const sketchCount = maxLimit + diff; const sketchCount = maxLimit + diff;
const sketchNames = [...Array(sketchCount).keys()].map(() => v4()); const sketchNames = [...Array(sketchCount).keys()].map(() => v4());
await sketchNames const createExecutionQueue = new PQueue({
.map((name) => createApi.createSketch(toPosix(name), content)) concurrency: 5,
.reduce(async (acc, curr) => { autoStart: true,
await acc; });
return curr; sketchNames.forEach((name) =>
}, Promise.resolve() as Promise<unknown>); createExecutionQueue.add(() =>
createApi.createSketch(toPosix(name), content)
)
);
await createExecutionQueue.onIdle();
createApi.resetRequestRecording(); createApi.resetRequestRecording();
const sketches = await createApi.sketches(); const sketches = await createApi.sketches(maxLimit);
const allRequests = createApi.requestRecording.slice(); const allRequests = createApi.requestRecording.slice();
expect(sketches.length).to.be.equal(sketchCount); expect(sketches.length).to.be.equal(sketchCount);