0
Fork 0
mirror of https://github.com/penpot/penpot-plugins.git synced 2025-01-02 04:40:11 -05:00

feat: init e2e test

This commit is contained in:
Juanfran 2024-06-13 15:15:10 +02:00
parent 0eac44dd93
commit b0af7051ad
18 changed files with 6123 additions and 7016 deletions

View file

@ -1,2 +1,2 @@
ACCESS_TOKEN = ''
API_URL = 'http://localhost:3449/api/rpc/command'
E2E_LOGIN_EMAIL=""
E2E_LOGIN_PASSWORD=""

32
apps/e2e/eslint.config.js Normal file
View file

@ -0,0 +1,32 @@
import baseConfig from '../../eslint.config.js';
import typescriptEslintParser from '@typescript-eslint/parser';
import globals from 'globals';
export default [
...baseConfig,
{
languageOptions: {
parser: typescriptEslintParser,
parserOptions: { project: './apps/e2e/tsconfig.json' },
},
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {},
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.ts', '**/*.tsx'],
rules: {},
},
{
files: ['**/*.js', '**/*.jsx'],
rules: {},
},
{ ignores: ['vite.config.ts'] },
];

8
apps/e2e/project.json Normal file
View file

@ -0,0 +1,8 @@
{
"name": "e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"implicitDependencies": [],
"tags": ["type:e2e"],
"targets": {}
}

View file

@ -0,0 +1,512 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Plugins > create frame - text - rectable 1`] = `
[
{
":fills": [
{
":fill-color": "#FFFFFF",
":fill-opacity": 1,
},
],
":flip-x": null,
":flip-y": null,
":frame-id": "1",
":height": 0.01,
":hide-fill-on-export": false,
":id": "1",
":name": "Root Frame",
":parent-id": "1",
":points": [
{
":x": 0,
":y": 0,
},
{
":x": 0.01,
":y": 0,
},
{
":x": 0.01,
":y": 0.01,
},
{
":x": 0,
":y": 0.01,
},
],
":proportion": 1,
":proportion-lock": false,
":rotation": 0,
":selrect": {
":height": 0.01,
":width": 0.01,
":x": 0,
":x1": 0,
":x2": 0.01,
":y": 0,
":y1": 0,
":y2": 0.01,
},
":shapes": [
"2",
"3",
"4",
],
":strokes": [],
":transform": {
":a": 1,
":b": 0,
":c": 0,
":d": 1,
":e": 0,
":f": 0,
},
":transform-inverse": {
":a": 1,
":b": 0,
":c": 0,
":d": 1,
":e": 0,
":f": 0,
},
":type": ":frame",
":width": 0.01,
":x": 0,
":y": 0,
},
{
":content": {
":children": [
{
":children": [
{
":children": [
{
":fills": [
{
":fill-color": "#000000",
":fill-opacity": 1,
},
],
":font-family": "sourcesanspro",
":font-id": "sourcesanspro",
":font-size": "14",
":font-style": "normal",
":font-variant-id": "regular",
":font-weight": "400",
":letter-spacing": "0",
":line-height": "1.2",
":text": "Hello from plugin",
":text-align": "left",
":text-decoration": "none",
":text-transform": "none",
":typography-ref-file": null,
":typography-ref-id": null,
},
],
":fills": [
{
":fill-color": "#000000",
":fill-opacity": 1,
},
],
":font-family": "sourcesanspro",
":font-id": "sourcesanspro",
":font-size": "14",
":font-style": "normal",
":font-variant-id": "regular",
":font-weight": "400",
":letter-spacing": "0",
":line-height": "1.2",
":text-align": "left",
":text-decoration": "none",
":text-transform": "none",
":type": "paragraph",
":typography-ref-file": null,
":typography-ref-id": null,
},
],
":type": "paragraph-set",
},
],
":type": "root",
},
":flip-x": null,
":flip-y": null,
":frame-id": "1",
":grow-type": ":auto-width",
":height": 17,
":id": "2",
":name": "Text",
":parent-id": "1",
":points": [
{
":x": 684,
":y": 540,
},
{
":x": 786,
":y": 540,
},
{
":x": 786,
":y": 557,
},
{
":x": 684,
":y": 557,
},
],
":position-data": [
{
":direction": "ltr",
":fills": [
{
":fill-color": "#000000",
":fill-opacity": 1,
},
],
":font-family": "sourcesanspro",
":font-size": "14px",
":font-style": "normal",
":font-weight": "400",
":height": 18,
":letter-spacing": "normal",
":text": "Hello from plugin",
":text-decoration": "none solid rgb(0, 0, 0)",
":text-transform": "none",
":width": 101.5313,
":x": 684,
":x1": 0,
":x2": 101.5313,
":y": 557,
":y1": -1,
":y2": 17,
},
],
":rotation": 0,
":selrect": {
":height": 17,
":width": 102,
":x": 684,
":x1": 684,
":x2": 786,
":y": 540,
":y1": 540,
":y2": 557,
},
":transform": {
":a": 1,
":b": 0,
":c": 0,
":d": 1,
":e": 0,
":f": 0,
},
":transform-inverse": {
":a": 1,
":b": 0,
":c": 0,
":d": 1,
":e": 0,
":f": 0,
},
":type": ":text",
":width": 102,
":x": 684,
":y": 540,
},
{
":fills": [
{
":fill-color": "#B1B2B5",
":fill-opacity": 1,
},
],
":flip-x": null,
":flip-y": null,
":frame-id": "1",
":height": 200,
":id": "3",
":name": "Rectangle",
":parent-id": "1",
":plugin-data": {
":plugin/TEST": {
"customKey": "customValue",
},
},
":points": [
{
":x": 684,
":y": 540,
},
{
":x": 884,
":y": 540,
},
{
":x": 884,
":y": 740,
},
{
":x": 684,
":y": 740,
},
],
":proportion": 1,
":proportion-lock": false,
":rotation": 0,
":rx": 0,
":ry": 0,
":selrect": {
":height": 200,
":width": 200,
":x": 684,
":x1": 684,
":x2": 884,
":y": 540,
":y1": 540,
":y2": 740,
},
":strokes": [],
":transform": {
":a": 1,
":b": 0,
":c": 0,
":d": 1,
":e": 0,
":f": 0,
},
":transform-inverse": {
":a": 1,
":b": 0,
":c": 0,
":d": 1,
":e": 0,
":f": 0,
},
":type": ":rect",
":width": 200,
":x": 684,
":y": 540,
},
{
":fills": [
{
":fill-color": "#FFFFFF",
":fill-opacity": 1,
},
],
":flip-x": null,
":flip-y": null,
":frame-id": "1",
":height": 300,
":hide-fill-on-export": false,
":id": "4",
":name": "Frame name",
":parent-id": "1",
":points": [
{
":x": 684,
":y": 540,
},
{
":x": 984,
":y": 540,
},
{
":x": 984,
":y": 840,
},
{
":x": 684,
":y": 840,
},
],
":proportion": 1,
":proportion-lock": false,
":rotation": 0,
":rx": 8,
":ry": 8,
":selrect": {
":height": 300,
":width": 300,
":x": 684,
":x1": 684,
":x2": 984,
":y": 540,
":y1": 540,
":y2": 840,
},
":shapes": [
"5",
],
":strokes": [],
":transform": {
":a": 1,
":b": 0,
":c": 0,
":d": 1,
":e": 0,
":f": 0,
},
":transform-inverse": {
":a": 1,
":b": 0,
":c": 0,
":d": 1,
":e": 0,
":f": 0,
},
":type": ":frame",
":width": 300,
":x": 684,
":y": 540,
},
{
":constraints-h": ":left",
":constraints-v": ":top",
":content": {
":children": [
{
":children": [
{
":children": [
{
":fills": [
{
":fill-color": "#000000",
":fill-opacity": 1,
},
],
":font-family": "sourcesanspro",
":font-id": "sourcesanspro",
":font-size": "14",
":font-style": "normal",
":font-variant-id": "regular",
":font-weight": "400",
":letter-spacing": "0",
":line-height": "1.2",
":text": "Hello from frame",
":text-align": "left",
":text-decoration": "none",
":text-transform": "none",
":typography-ref-file": null,
":typography-ref-id": null,
},
],
":fills": [
{
":fill-color": "#000000",
":fill-opacity": 1,
},
],
":font-family": "sourcesanspro",
":font-id": "sourcesanspro",
":font-size": "14",
":font-style": "normal",
":font-variant-id": "regular",
":font-weight": "400",
":letter-spacing": "0",
":line-height": "1.2",
":text-align": "left",
":text-decoration": "none",
":text-transform": "none",
":type": "paragraph",
":typography-ref-file": null,
":typography-ref-id": null,
},
],
":type": "paragraph-set",
},
],
":type": "root",
},
":flip-x": null,
":flip-y": null,
":frame-id": "4",
":grow-type": ":auto-width",
":height": 17,
":id": "5",
":name": "Text",
":parent-id": "4",
":points": [
{
":x": 10,
":y": 10,
},
{
":x": 109,
":y": 10,
},
{
":x": 109,
":y": 27,
},
{
":x": 10,
":y": 27,
},
],
":position-data": [
{
":direction": "ltr",
":fills": [
{
":fill-color": "#000000",
":fill-opacity": 1,
},
],
":font-family": "sourcesanspro",
":font-size": "14px",
":font-style": "normal",
":font-weight": "400",
":height": 18,
":letter-spacing": "normal",
":text": "Hello from frame",
":text-decoration": "none solid rgb(0, 0, 0)",
":text-transform": "none",
":width": 98.7344,
":x": 10,
":x1": 0,
":x2": 98.7344,
":y": 27,
":y1": -1,
":y2": 17,
},
],
":rotation": 0,
":selrect": {
":height": 17,
":width": 99,
":x": 10,
":x1": 10,
":x2": 109,
":y": 10,
":y1": 10,
":y2": 27,
},
":transform": {
":a": 1,
":b": 0,
":c": 0,
":d": 1,
":e": 0,
":f": 0,
},
":transform-inverse": {
":a": 1,
":b": 0,
":c": 0,
":d": 1,
":e": 0,
":f": 0,
},
":type": ":text",
":width": 99,
":x": 10,
":y": 10,
},
]
`;

View file

@ -0,0 +1,17 @@
export interface FileRpc {
'~:name': string;
'~:revn': number;
'~:id': string;
'~:is-shared': boolean;
'~:version': number;
'~:project-id': string;
'~:data': {
'~:pages': string[];
'~:objects': string[];
'~:styles': string[];
'~:components': string[];
'~:styles-v2': string[];
'~:components-v2': string[];
'~:features': string[];
};
}

View file

@ -0,0 +1,11 @@
import testingPlugin from './plugins/create-frame-text-rect';
import { Agent } from './utils/agent';
describe('Plugins', () => {
it('create frame - text - rectable', async () => {
const agent = await Agent();
const result = await agent.runCode(testingPlugin.toString());
expect(result).toMatchSnapshot();
});
});

View file

@ -0,0 +1,64 @@
import type {
PenpotFrame,
PenpotRectangle,
PenpotText,
} from '@penpot/plugin-types';
export default function () {
function createText(text: string): PenpotText | undefined {
const textNode = penpot.createText(text);
if (!textNode) {
return;
}
textNode.x = penpot.viewport.center.x;
textNode.y = penpot.viewport.center.y;
return textNode;
}
function createRectangle(): PenpotRectangle {
const rectangle = penpot.createRectangle();
rectangle.setPluginData('customKey', 'customValue');
rectangle.x = penpot.viewport.center.x;
rectangle.y = penpot.viewport.center.y;
rectangle.resize(200, 200);
return rectangle;
}
function createFrame(): PenpotFrame {
const frame = penpot.createFrame();
frame.name = 'Frame name';
console.log(penpot.viewport.center.x);
frame.x = penpot.viewport.center.x;
frame.y = penpot.viewport.center.y;
frame.borderRadius = 8;
frame.resize(300, 300);
const text = penpot.createText('Hello from frame');
if (!text) {
throw new Error('Could not create text');
}
text.x = 10;
text.y = 10;
frame.appendChild(text);
return frame;
}
createText('Hello from plugin');
createRectangle();
createFrame();
}

155
apps/e2e/src/utils/agent.ts Normal file
View file

@ -0,0 +1,155 @@
import puppeteer from 'puppeteer';
import { PenpotApi } from './api';
import { getFileUrl } from './get-file-url';
interface Shape {
':id': string;
':frame-id'?: string;
':parent-id'?: string;
':shapes'?: string[];
}
function replaceIds(shapes: Shape[]) {
let id = 1;
const getId = () => {
return String(id++);
};
function replaceChildrenId(id: string, newId: string) {
for (const node of shapes) {
if (node[':parent-id'] === id) {
node[':parent-id'] = newId;
}
if (node[':frame-id'] === id) {
node[':frame-id'] = newId;
}
if (node[':shapes']) {
node[':shapes'] = node[':shapes']?.map((shapeId) => {
return shapeId === id ? newId : shapeId;
});
}
}
}
for (const node of shapes) {
const previousId = node[':id'] as string;
node[':id'] = getId();
replaceChildrenId(previousId, node[':id']);
}
}
export async function Agent() {
console.log('Initializing Penpot API...');
const penpotApi = await PenpotApi();
console.log('Creating file...');
const file = await penpotApi.createFile();
console.log('File created with id:', file['~:id']);
const fileUrl = getFileUrl(file);
console.log('File URL:', fileUrl);
console.log('Launching browser...');
const browser = await puppeteer.launch({});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
console.log('Setting authentication cookie...');
page.setCookie({
name: 'auth-token',
value: penpotApi.getAuth().split('=')[1],
domain: 'localhost',
path: '/',
expires: (Date.now() + 3600 * 1000) / 1000,
});
console.log('Navigating to file URL...');
await page.goto(fileUrl);
await page.waitForSelector('[data-testid="viewport"]');
console.log('Page loaded and viewport selector found.');
page
.on('console', async (message) => {
console.log(`${message.type()} ${message.text()}`);
})
.on('pageerror', (message) => {
console.error('Page error:', message);
});
const finish = async () => {
console.log('Deleting file and closing browser...');
await penpotApi.deleteFile(file['~:id']);
await browser.close();
console.log('Clean up done.');
};
return {
async runCode(
code: string,
options: { screenshot?: string; autoFinish?: boolean } = {
screenshot: '',
autoFinish: true,
}
) {
console.log('Running plugin code...');
await page.evaluate((testingPlugin) => {
(globalThis as any).ɵloadPlugin({
pluginId: 'TEST',
name: 'Test',
code: `
(${testingPlugin})();
`,
icon: '',
description: '',
permissions: ['content:read', 'content:write'],
});
}, code);
console.log('Waiting for save status...');
await page.waitForSelector(
'.main_ui_workspace_right_header__saved-status',
{
timeout: 10000,
}
);
console.log('Save status found.');
if (options.screenshot) {
console.log('Taking screenshot:', options.screenshot);
await page.screenshot({ path: options.screenshot });
}
return new Promise((resolve) => {
page.once('console', async (msg) => {
const args = (await Promise.all(
msg.args().map((arg) => arg.jsonValue())
)) as Record<string, unknown>[];
const result = Object.values(args[1]) as Shape[];
replaceIds(result);
console.log('IDs replaced in result.');
resolve(result);
if (options.autoFinish) {
console.log('Auto finish enabled. Cleaning up...');
finish();
}
});
console.log('Evaluating debug.dump_objects...');
page.evaluate(`
debug.dump_objects();
`);
});
},
finish,
};
}

85
apps/e2e/src/utils/api.ts Normal file
View file

@ -0,0 +1,85 @@
import { FileRpc } from '../models/file-rpc.model';
const apiUrl = 'http://localhost:3449';
export async function PenpotApi() {
if (!process.env['E2E_LOGIN_EMAIL']) {
throw new Error('E2E_LOGIN_EMAIL not set');
}
const resultLoginRequest = await fetch(
`${apiUrl}/api/rpc/command/login-with-password`,
{
method: 'POST',
headers: {
'Content-Type': 'application/transit+json',
},
body: JSON.stringify({
'~:email': process.env['E2E_LOGIN_EMAIL'],
'~:password': process.env['E2E_LOGIN_PASSWORD'],
}),
}
);
const loginData = await resultLoginRequest.json();
const authToken = resultLoginRequest.headers
.get('set-cookie')
?.split(';')
.at(0);
if (!authToken) {
throw new Error('Login failed');
}
return {
getAuth: () => authToken,
createFile: async () => {
const createFileRequest = await fetch(
`${apiUrl}/api/rpc/command/create-file`,
{
method: 'POST',
headers: {
'Content-Type': 'application/transit+json',
cookie: authToken,
credentials: 'include',
},
body: JSON.stringify({
'~:name': `test file ${new Date().toISOString()}`,
'~:project-id': loginData['~:default-project-id'],
'~:features': {
'~#set': [
'fdata/objects-map',
'fdata/pointer-map',
'fdata/shape-data-type',
'components/v2',
'styles/v2',
'layout/grid',
'plugins/runtime',
],
},
}),
}
);
return (await createFileRequest.json()) as FileRpc;
},
deleteFile: async (fileId: string) => {
const deleteFileRequest = await fetch(
`${apiUrl}/api/rpc/command/delete-file`,
{
method: 'POST',
headers: {
'Content-Type': 'application/transit+json',
cookie: authToken,
credentials: 'include',
},
body: JSON.stringify({
'~:id': fileId,
}),
}
);
return deleteFileRequest;
},
};
}

View file

@ -0,0 +1,3 @@
export function cleanId(id: string) {
return id.replace('~u', '');
}

View file

@ -0,0 +1,10 @@
import { FileRpc } from '../models/file-rpc.model';
import { cleanId } from './clean-id';
export function getFileUrl(file: FileRpc) {
const projectId = cleanId(file['~:project-id']);
const fileId = cleanId(file['~:id']);
const pageId = cleanId(file['~:data']['~:pages'][0]);
return `http://localhost:3449/#/workspace/${projectId}/${fileId}?page-id=${pageId}`;
}

27
apps/e2e/tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vitest.config.ts",
"src/**/*.ts",
"../../libs/plugin-types/index.d.ts"
]
}

23
apps/e2e/vite.config.ts Normal file
View file

@ -0,0 +1,23 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
export default defineConfig({
root: __dirname,
cacheDir: '../../node_modules/.vite/e2e',
test: {
testTimeout: 20000,
watch: false,
globals: true,
cache: {
dir: '../node_modules/.vitest',
},
environment: 'happy-dom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: '../coverage/e2e',
provider: 'v8',
},
setupFiles: ['dotenv/config'],
},
});

81
docs/test-e2e.md Normal file
View file

@ -0,0 +1,81 @@
## End-to-End (E2E) Testing Guide
### Setting Up
1. **Configure Environment Variables**
Create and populate the `.env` file with a valid user mail & password:
```env
E2E_LOGIN_EMAIL="test@penpot.app"
E2E_LOGIN_PASSWORD="123123123"
```
2. **Run E2E Tests**
Use the following command to execute the E2E tests:
```bash
npm run test:e2e
```
### Writing Tests
1. **Adding Tests**
Place your test files in the `/apps/e2e/src/**/*.spec.ts` directory. Below is an example of a test file:
```ts
import testingPlugin from './plugins/create-frame-text-rect';
import { Agent } from './utils/agent';
describe('Plugins', () => {
it('create frame - text - rectangle', async () => {
const agent = await Agent();
const result = await agent.runCode(testingPlugin.toString());
expect(result).toMatchSnapshot();
});
});
```
**Explanation**:
- `Agent` opens a browser, logs into Penpot, and creates a file.
- `runCode` executes the plugin code and returns the file state after execution.
2. **Using `runCode` Method**
The `runCode` method takes the plugin code as a string:
```ts
const result = await agent.runCode(testingPlugin.toString());
```
It can also accept an options object:
```ts
const result = await agent.runCode(testingPlugin.toString(), {
autoFinish: false, // default: true
screenshot: 'capture.png', // default: ''
});
// Finish will close the browser & delete the file
agent.finish();
```
3. **Snapshot Testing**
The `toMatchSnapshot` method stores the result and throws an error if the content does not match the previous result:
```ts
expect(result).toMatchSnapshot();
```
Snapshots are stored in the `apps/e2e/src/__snapshots__/*.spec.ts.snap` directory.
If you need to refresh all the snapshopts run the test with the update option:
```bash
npm run test:e2e -- --update
```

View file

@ -44,6 +44,10 @@ export default [
sourceTag: 'type:util',
onlyDependOnLibsWithTags: ['type:util'],
},
{
sourceTag: 'type:e2e',
onlyDependOnLibsWithTags: ['type:ui', 'type:util'],
},
],
},
],

View file

@ -24,6 +24,10 @@ export function loadManifest(url: string): Promise<Manifest> {
}
export function loadManifestCode(manifest: Manifest): Promise<string> {
if (!manifest.host && !manifest.code.startsWith('http')) {
return Promise.resolve(manifest.code);
}
return fetch(getValidUrl(manifest.host, manifest.code)).then((response) => {
if (response.ok) {
return response.text();

12097
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -21,6 +21,7 @@
"lint": "nx run-many --all --target=lint --parallel",
"lint:affected": "npx nx affected --target=lint",
"test": "nx run-many -t test --parallel -p plugins-runtime lorem-ipsum-plugin",
"test:e2e": "npx nx test e2e",
"registry": "nx local-registry",
"prepare": "husky",
"create:api-docs": "npx typedoc --tsconfig libs/plugins-runtime/tsconfig.lib.json --customCss ./tools/typedoc.css",
@ -95,6 +96,7 @@
"@angular/router": "18.0.1",
"axios": "^1.6.0",
"feather-icons": "^4.29.2",
"puppeteer": "^22.11.0",
"rxjs": "~7.8.0",
"ses": "^1.5.0",
"tslib": "^2.3.0",