diff --git a/packages/core/expressions/cypress.config.ts b/packages/core/expressions/cypress.config.ts new file mode 100644 index 0000000000..6aad897723 --- /dev/null +++ b/packages/core/expressions/cypress.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'cypress' + +export default defineConfig({ + component: { + devServer: { + framework: 'vue', + bundler: 'vite', + }, + }, +}) diff --git a/packages/core/expressions/cypress/fixtures/example.json b/packages/core/expressions/cypress/fixtures/example.json new file mode 100644 index 0000000000..02e4254378 --- /dev/null +++ b/packages/core/expressions/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/packages/core/expressions/cypress/support/commands.ts b/packages/core/expressions/cypress/support/commands.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/core/expressions/cypress/support/component-index.html b/packages/core/expressions/cypress/support/component-index.html new file mode 100644 index 0000000000..ac6e79fd83 --- /dev/null +++ b/packages/core/expressions/cypress/support/component-index.html @@ -0,0 +1,12 @@ + + + + + + + Components App + + +
+ + \ No newline at end of file diff --git a/packages/core/expressions/cypress/support/component.ts b/packages/core/expressions/cypress/support/component.ts new file mode 100644 index 0000000000..3e26a2696c --- /dev/null +++ b/packages/core/expressions/cypress/support/component.ts @@ -0,0 +1,32 @@ +// *********************************************************** +// This example support/component.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') + +import { mount } from 'cypress/vue' + +// Augment the Cypress namespace to include type definitions for +// your custom command. +// Alternatively, can be defined in cypress/support/component.d.ts +// with a at the top of your spec. + +Cypress.Commands.add('mount', mount) + +// Example use: +// cy.mount(MyComponent) diff --git a/packages/core/expressions/cypress/support/index.d.ts b/packages/core/expressions/cypress/support/index.d.ts new file mode 100644 index 0000000000..e7698a3a20 --- /dev/null +++ b/packages/core/expressions/cypress/support/index.d.ts @@ -0,0 +1,27 @@ +declare namespace Cypress { + interface Chainable { + /** + * @description Custom alias command for cy.get() to select DOM element by data-testid attribute. + * @param {string} dataTestId + * @example cy.dataTestId('kong-auth-login-submit') + */ + getTestId(dataTestId: string): Chainable + + /** + * @description Custom alias command for cy.find() to select DOM element by data-testid attribute. + * @param {string} dataTestId + * @example cy.findTestId('kong-auth-login-submit') + */ + findTestId(dataTestId: string): Chainable + + /** + * @description Custom command to mount a Vue component inside Cypress browser. + * @example cy.mount(component, optionsOrProps) + * @param {any} component target component + * @param {any} options Options or props + */ + mount(component: any, options?: any): Chainable + + assertValueCopiedToClipboard(value: string): Chainable + } +} diff --git a/packages/core/expressions/cypress/support/index.ts b/packages/core/expressions/cypress/support/index.ts new file mode 100644 index 0000000000..a626f89055 --- /dev/null +++ b/packages/core/expressions/cypress/support/index.ts @@ -0,0 +1,2 @@ +// Import custom Cypress commands +import './commands' diff --git a/packages/core/expressions/package.json b/packages/core/expressions/package.json index 99374645d4..93326ddb30 100644 --- a/packages/core/expressions/package.json +++ b/packages/core/expressions/package.json @@ -31,8 +31,8 @@ "stylelint": "stylelint --allow-empty-input './src/**/*.{css,scss,sass,less,styl,vue}'", "stylelint:fix": "stylelint --allow-empty-input './src/**/*.{css,scss,sass,less,styl,vue}' --fix", "typecheck": "vue-tsc -p './tsconfig.build.json' --noEmit", - "test:component": "BABEL_ENV=cypress cross-env FORCE_COLOR=1 cypress run --component -b chrome --spec './src/**/*.cy.ts' --project '../../../.'", - "test:component:open": "BABEL_ENV=cypress cross-env FORCE_COLOR=1 cypress open --component -b chrome --project '../../../.'", + "test:component": "BABEL_ENV=cypress cross-env FORCE_COLOR=1 cypress run --component -b chrome --spec './src/**/*.cy.ts' --project './'", + "test:component:open": "BABEL_ENV=cypress cross-env FORCE_COLOR=1 cypress open --component -b chrome --project './'", "test:unit": "cross-env FORCE_COLOR=1 vitest run", "test:unit:open": "cross-env FORCE_COLOR=1 vitest --ui" }, @@ -70,6 +70,9 @@ "vue": "^3.4.31" }, "dependencies": { - "@kong-ui-public/core": "workspace:^" + "@kong-ui-public/core": "workspace:^", + "@kong-ui-public/forms": "workspace:^", + "@kong/icons": "^1.14.2", + "uuid": "^9.0.1" } } diff --git a/packages/core/expressions/sandbox/App.vue b/packages/core/expressions/sandbox/App.vue index 6ded682b41..815f8788fd 100644 --- a/packages/core/expressions/sandbox/App.vue +++ b/packages/core/expressions/sandbox/App.vue @@ -29,6 +29,24 @@ @parse-result-update="onParseResultUpdate" /> + Test with Router Playground + + + + +

ParseResult:

{{ parseResult }}
@@ -41,6 +59,7 @@ import { ref, watch } from 'vue' import type { SchemaDefinition } from '../src' import { ExpressionsEditor, HTTP_SCHEMA_DEFINITION, STREAM_SCHEMA_DEFINITION } from '../src' +import RouterPlaygroundModal from '../src/components/RouterPlaygroundModal.vue' type NamedSchemaDefinition = { name: string; definition: SchemaDefinition } @@ -68,21 +87,18 @@ const btoa = (s: string) => window.btoa(s) const expression = ref(expressionPresets[0]) const schemaDefinition = ref(schemaPresets[0]) const parseResult = ref('') +const isVisible = ref(false) const onParseResultUpdate = (result: any) => { parseResult.value = JSON.stringify(result, null, 2) } +const handleCommit = (exp: string) => { + expression.value = exp + isVisible.value = false +} + watch(schemaDefinition, (newSchemaDefinition) => { schemaDefinition.value = newSchemaDefinition }) - - diff --git a/packages/core/expressions/sandbox/index.ts b/packages/core/expressions/sandbox/index.ts index 52668a0a54..70c15aa2a0 100644 --- a/packages/core/expressions/sandbox/index.ts +++ b/packages/core/expressions/sandbox/index.ts @@ -1,6 +1,9 @@ +import '@kong/kongponents/dist/style.css' import { createApp } from 'vue' import App from './App.vue' +import Kongponents from '@kong/kongponents' const app = createApp(App) +app.use(Kongponents) app.mount('#app') diff --git a/packages/core/expressions/src/components/ExpressionsEditor.vue b/packages/core/expressions/src/components/ExpressionsEditor.vue index 5ffb5d0dfd..142c31e148 100644 --- a/packages/core/expressions/src/components/ExpressionsEditor.vue +++ b/packages/core/expressions/src/components/ExpressionsEditor.vue @@ -2,6 +2,7 @@
@@ -26,8 +27,10 @@ const props = withDefaults(defineProps<{ schema: Schema, parseDebounce?: number, inactiveUntilFocused?: boolean, + testId?: string, }>(), { parseDebounce: 500, + testId: 'expressions-editor', }) const expression = defineModel({ required: true }) diff --git a/packages/core/expressions/src/components/MonacoEditor.vue b/packages/core/expressions/src/components/MonacoEditor.vue new file mode 100644 index 0000000000..27ff9d08cd --- /dev/null +++ b/packages/core/expressions/src/components/MonacoEditor.vue @@ -0,0 +1,103 @@ + + + diff --git a/packages/core/expressions/src/components/PageHeader.vue b/packages/core/expressions/src/components/PageHeader.vue new file mode 100644 index 0000000000..30214f7835 --- /dev/null +++ b/packages/core/expressions/src/components/PageHeader.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/packages/core/expressions/src/components/RequestCard.vue b/packages/core/expressions/src/components/RequestCard.vue new file mode 100644 index 0000000000..8dd17e4634 --- /dev/null +++ b/packages/core/expressions/src/components/RequestCard.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/packages/core/expressions/src/components/RequestImportModal.vue b/packages/core/expressions/src/components/RequestImportModal.vue new file mode 100644 index 0000000000..8125d1daa2 --- /dev/null +++ b/packages/core/expressions/src/components/RequestImportModal.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/packages/core/expressions/src/components/RequestModal.vue b/packages/core/expressions/src/components/RequestModal.vue new file mode 100644 index 0000000000..b83b50351d --- /dev/null +++ b/packages/core/expressions/src/components/RequestModal.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/packages/core/expressions/src/components/RouterPlayground.cy.ts b/packages/core/expressions/src/components/RouterPlayground.cy.ts new file mode 100644 index 0000000000..329bf1cf78 --- /dev/null +++ b/packages/core/expressions/src/components/RouterPlayground.cy.ts @@ -0,0 +1,220 @@ +import RouterPlayground from './RouterPlayground.vue' + +import { mount } from 'cypress/vue' +import type { App } from 'vue' +import Kongponents from '@kong/kongponents' +import '@kong/kongponents/dist/style.css' + +Cypress.Commands.add('getTestId', (dataTestId: string): any => { + return cy.get(`[data-testid="${dataTestId}"]`) +}) + +Cypress.Commands.add('mount', (component, options = {}) => { + // Setup options object + options.global = options.global || {} + options.global.stubs = options.global.stubs || {} + options.global.stubs.transition = false + options.global.components = options.global.components || {} + options.global.plugins = options.global.plugins || [] + + // Add plugins + options.global.plugins.push({ + install(app: App) { + // Kongponents + app.use(Kongponents) + }, + }) + + return mount(component, options) +}) + +Cypress.Commands.add('assertValueCopiedToClipboard', value => { + cy.window().then(win => { + win.navigator.clipboard.readText().then(text => { + expect(text).to.eq(value) + }) + }) +}) + + +describe('', () => { + beforeEach(() => { + cy.viewport(800, 800) + }) + + it('should show router playground', () => { + cy.mount(RouterPlayground) + + cy.getTestId('expression-header').should('be.visible') + cy.getTestId('expressions-editor').should('be.visible') + cy.getTestId('expressions-inspirations').should('be.visible') + cy.getTestId('requests-header').should('be.visible') + cy.getTestId('btn-commit').should('be.visible') + cy.getTestId('btn-import').should('be.visible') + cy.getTestId('empty-state-requests').should('be.visible') + }) + + it('page-header slot', () => { + cy.mount(RouterPlayground, { + slots: { + 'page-header': '
Custom Header
', + }, + }) + + cy.getTestId('custom-header').should('be.visible') + }) + + it('initial expression', () => { + cy.mount(RouterPlayground, { + props: { + initialExpression: 'http.host == "localhost"', + }, + }) + + cy.getTestId('expressions-editor').contains('http.host == "localhost"') + cy.getTestId('expressions-inspirations').should('not.exist') + }) + + it('inspirations', () => { + cy.mount(RouterPlayground) + + cy.getTestId('btn-inspiration-0').click() + cy.getTestId('expressions-editor').contains('http.host == "localhost"') + cy.getTestId('btn-inspiration-0').should('not.exist') + }) + + it('expression changed', () => { + const onChangeSpy = cy.spy().as('onChangeSpy') + const onCommitSpy = cy.spy().as('onCommitSpy') + cy.mount(RouterPlayground, { + props: { + initialExpression: 'http.host == "localhost"', + onChange: onChangeSpy, + onCommit: onCommitSpy, + }, + }) + cy.get('.view-lines').type(' && http.method == "GET"') + cy.get('@onChangeSpy').should('have.been.calledWith', 'http.host == "localhost" && http.method == "GET"') + + cy.getTestId('btn-commit').click() + cy.get('@onCommitSpy').should('have.been.calledWith', 'http.host == "localhost" && http.method == "GET"') + }) + + it('requests placeholder', () => { + cy.mount(RouterPlayground) + + cy.getTestId('empty-state-requests').should('be.visible') + }) + + function addRequest({ + url, + method, + headers, + }: { + url: string + method: string + headers?: Record[] + }) { + cy.getTestId('btn-add-request').click() + cy.getTestId('url-input').should('be.visible') + cy.getTestId('url-input').type(url) + + cy.get('#method').select(method) + + if (headers?.length) { + headers.forEach((header, index) => { + cy.getTestId('keyname-input').type(header.key) + cy.getTestId('add-key').click() + cy.get(`:nth-child(${index + 3}) > .form-control`).type(header.value) + }) + } + + cy.getTestId('modal-action-button').click() + } + + it('add/remove requests', () => { + cy.mount(RouterPlayground) + + addRequest({ + url: 'http://localhost:8000', + method: 'GET', + headers: [ + { key: 'Foo', value: 'bar' }, + ], + }) + + cy.get('.request-card').should('have.length', 1) + cy.get('.request-card').first().contains('http://localhost:8000') + cy.get('.request-card').first().find('.close-btn').click({ force: true }) + cy.get('.request-card').should('not.exist') + }) + + it('matching requests', () => { + cy.mount(RouterPlayground) + const requestIds: string[] = [] + + addRequest({ + url: 'http://localhost:8000', + method: 'GET', + headers: [ + { key: 'Foo', value: 'bar' }, + ], + }) + + addRequest({ + url: 'https://www.konghq.com', + method: 'GET', + }) + + // eslint-disable-next-line cypress/unsafe-to-chain-command + cy.get('.request-card').each($card => { + requestIds.push($card.data('testid')) + }).then(() => { + cy.get('.view-lines').type('http.host == "localhost"') + cy.getTestId(requestIds[0]).should('have.class', 'active') + cy.getTestId(requestIds[1]).should('not.have.class', 'active') + }) + }) + + it('cache requests', () => { + cy.clearAllLocalStorage() + + cy.mount(RouterPlayground) + + addRequest({ + url: 'http://localhost:8000', + method: 'GET', + }) + + cy.mount(RouterPlayground) + cy.get('.request-card').should('have.length', 1) + + cy.getTestId('clear-requests-link').click() + cy.getTestId('modal-action-button').click() + + cy.mount(RouterPlayground) + cy.get('.request-card').should('not.exist') + }) + + const REQUESTS_TEXT = '[{"method":"POST","headers":{},"protocol":"http","host":"localhost","port":8080,"path":"/","id":"42eafd0a-287b-441d-b88f-f6fe4b44da0d"},{"method":"GET","headers":{},"protocol":"https","host":"konghq.com","port":443,"path":"/abc","id":"da8f04a9-c8c5-495d-a26c-f7bc132a3a64"}]' + + it('import/export requests', () => { + cy.mount(RouterPlayground) + cy.getTestId('btn-import').click() + cy.getTestId('import-requests-editor').should('be.visible') + cy.getTestId('import-requests-editor').find('.view-lines').type(REQUESTS_TEXT, { + parseSpecialCharSequences: false, + }) + cy.getTestId('modal-action-button').click() + cy.get('.request-card').should('have.length', 2) + cy.get('.request-card').first().contains('http://localhost:8080/') + cy.get('.request-card:nth-child(2)').contains('https://konghq.com/abc') + + cy.window().then(() => { + // eslint-disable-next-line cypress/unsafe-to-chain-command + cy.getTestId('btn-export').focus().click() + cy.assertValueCopiedToClipboard(REQUESTS_TEXT) + }) + }) + +}) diff --git a/packages/core/expressions/src/components/RouterPlayground.vue b/packages/core/expressions/src/components/RouterPlayground.vue new file mode 100644 index 0000000000..114d017a4e --- /dev/null +++ b/packages/core/expressions/src/components/RouterPlayground.vue @@ -0,0 +1,493 @@ + + + + + diff --git a/packages/core/expressions/src/components/RouterPlaygroundModal.vue b/packages/core/expressions/src/components/RouterPlaygroundModal.vue new file mode 100644 index 0000000000..525b5675d3 --- /dev/null +++ b/packages/core/expressions/src/components/RouterPlaygroundModal.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/packages/core/expressions/src/definitions.ts b/packages/core/expressions/src/definitions.ts new file mode 100644 index 0000000000..bf8a9dc4ea --- /dev/null +++ b/packages/core/expressions/src/definitions.ts @@ -0,0 +1,25 @@ +export type Request = { + id: string; + protocol: string; + host: string; + port: number; + path: string; + method?: string; + headers?: { [k: string]: string | string[] }; + sni?: string; +} + +export const DEFAULT_PROTOCOL_PORTS = { + http: 80, + https: 443, + grpc: 80, + grpcs: 443, + ws: 80, + wss: 443, +} + +export const HTTP_PROTOCOLS = new Set(['http', 'https']) + +export const SECURED_PROTOCOLS = new Set(['https', 'grpcs', 'wss']) + +export const SUPPORTED_PROTOCOLS = new Set(Object.keys(DEFAULT_PROTOCOL_PORTS)) diff --git a/packages/core/expressions/src/index.ts b/packages/core/expressions/src/index.ts index ec9ebd21b3..488c03051c 100644 --- a/packages/core/expressions/src/index.ts +++ b/packages/core/expressions/src/index.ts @@ -1,8 +1,9 @@ import ExpressionsEditor from './components/ExpressionsEditor.vue' +import RouterPlaygroudModal from './components/RouterPlaygroundModal.vue' export * as Atc from '@kong/atc-router' export * from './schema' -export { ExpressionsEditor } +export { ExpressionsEditor, RouterPlaygroudModal } declare const asyncInit: Promise export { asyncInit } diff --git a/packages/core/expressions/src/utils.ts b/packages/core/expressions/src/utils.ts new file mode 100644 index 0000000000..00b6dfc9ec --- /dev/null +++ b/packages/core/expressions/src/utils.ts @@ -0,0 +1,57 @@ +import { v4 as uuidv4 } from 'uuid' +import { + DEFAULT_PROTOCOL_PORTS, HTTP_PROTOCOLS, SECURED_PROTOCOLS, SUPPORTED_PROTOCOLS, type Request, +} from './definitions' + +export const validateRequest = (request: Request) => { + if (!SUPPORTED_PROTOCOLS.has(request.protocol)) throw new Error(`Unsupported protocol: ${request.protocol}. (Supported protocols: ${Array.from(SUPPORTED_PROTOCOLS.values()).join(', ')})`) +} + +/** + * Transforms and checks if the request is valid + * @param request + * @returns + */ +export const transformCheckRequest = (request: Partial): string | undefined => { + if (request.id === undefined) { + request.id = uuidv4() + } + + if (!request.protocol) { + return 'Protocol is required' + } + + if (!SUPPORTED_PROTOCOLS.has(request.protocol)) { + return `Protocol is unsupported (Supported protocols: ${Array.from(SUPPORTED_PROTOCOLS).join(', ')})` + } + + if (!request.port) { + request.port = (DEFAULT_PROTOCOL_PORTS as any)[request.protocol] + } + + if (!request.host) { + return 'Host is required' + } + + if (!request.path) { + return 'Path is required' + } + + if (!SECURED_PROTOCOLS.has(request.protocol) && request.sni) { + return `SNI is not available for "${request.protocol}" protocol` + } + + if (HTTP_PROTOCOLS.has(request.protocol)) { + if (Array.isArray(request.method)) { + request.method = request.method[0] + } + + if (!request.method) { + return `Method is required for "${request.protocol}" protocol` + } + + if (!/^[A-Z]+$/g.test(request.method)) { + return 'Method should be all capitalized' + } + } +} diff --git a/packages/core/expressions/vite.config.ts b/packages/core/expressions/vite.config.ts index ec1423e694..134b4944b0 100644 --- a/packages/core/expressions/vite.config.ts +++ b/packages/core/expressions/vite.config.ts @@ -29,11 +29,7 @@ const config = mergeConfig(sharedViteConfig, defineConfig({ topLevelAwait({ promiseExportName: 'asyncInit', }), - // We don't need this plugin to bundle the library. Only for sandbox previews. - // See: https://github.com/vdesjs/vite-plugin-monaco-editor/issues/21 - ...process.env.USE_SANDBOX - ? [((monacoEditorPlugin as any).default as typeof monacoEditorPlugin)({})] - : [], + [((monacoEditorPlugin as any).default as typeof monacoEditorPlugin)({})], ], })) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4193aca321..4fac0d0d59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -545,6 +545,15 @@ importers: '@kong-ui-public/core': specifier: workspace:^ version: link:../core + '@kong-ui-public/forms': + specifier: workspace:^ + version: link:../forms + '@kong/icons': + specifier: ^1.14.2 + version: 1.14.2(vue@3.4.31(typescript@5.3.3)) + uuid: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@kong/atc-router': specifier: 1.6.0-rc.1 @@ -1736,7 +1745,6 @@ packages: '@evilmartians/lefthook@1.7.1': resolution: {integrity: sha512-Wp8DaTMHZM1tUV4Mow6nG+6zq+giruD5054zHmFIDLXlPQxqYxnZMqJg0aYxe16vYwqFmH6NIClEMRdtGucO0Q==} - cpu: [x64, arm64, ia32] os: [darwin, linux, win32] hasBin: true