diff --git a/e2e/pom/protos.ts b/e2e/pom/protos.ts new file mode 100644 index 000000000..4ba902731 --- /dev/null +++ b/e2e/pom/protos.ts @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { uiGoto } from '@e2e/utils/ui'; +import { expect, type Page } from '@playwright/test'; + +const locator = { + getProtoNavBtn: (page: Page) => + page.getByRole('link', { name: 'Protos' }), + getAddProtoBtn: (page: Page) => + page.getByRole('button', { name: 'Add Proto' }), + getAddBtn: (page: Page) => + page.getByRole('button', { name: 'Add', exact: true }), +}; + +const assert = { + isIndexPage: async (page: Page) => { + await expect(page).toHaveURL((url) => url.pathname.endsWith('/protos')); + const title = page.getByRole('heading', { name: 'Protos' }); + await expect(title).toBeVisible(); + }, + isAddPage: async (page: Page) => { + await expect(page).toHaveURL((url) => + url.pathname.endsWith('/protos/add') + ); + const title = page.getByRole('heading', { name: 'Add Proto' }); + await expect(title).toBeVisible(); + }, + isDetailPage: async (page: Page) => { + await expect(page).toHaveURL((url) => + url.pathname.includes('/protos/detail') + ); + const title = page.getByRole('heading', { name: 'Proto Detail' }); + await expect(title).toBeVisible(); + }, +}; + +const goto = { + toIndex: (page: Page) => uiGoto(page, '/protos'), + toAdd: (page: Page) => uiGoto(page, '/protos/add'), +}; + +export const protosPom = { + ...locator, + ...assert, + ...goto, +}; diff --git a/e2e/tests/protos.crud-all-fields.spec.ts b/e2e/tests/protos.crud-all-fields.spec.ts new file mode 100644 index 000000000..0df39d7c9 --- /dev/null +++ b/e2e/tests/protos.crud-all-fields.spec.ts @@ -0,0 +1,142 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { protosPom } from '@e2e/pom/protos'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { expect } from '@playwright/test'; + +import { API_PROTOS } from '@/config/constant'; +import type { APISIXType } from '@/types/schema/apisix'; + +const protoContent = `syntax = "proto3"; +package test; + +message TestMessage { + string name = 1; + int32 age = 2; + string email = 3; +}`; + +let createdProtoId: string; + +test.describe('CRUD proto with all fields', () => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + // cleanup: delete the proto + if (createdProtoId) { + await e2eReq.delete(`${API_PROTOS}/${createdProtoId}`).catch(() => { + // ignore error if proto doesn't exist + }); + } + }); + + test('should create a proto with all fields', async ({ page }) => { + await test.step('navigate to add proto page', async () => { + await protosPom.toAdd(page); + await protosPom.isAddPage(page); + }); + + await test.step('fill in all fields', async () => { + // Fill Content (ID is auto-generated, proto only has content field) + await page.getByLabel('Content').fill(protoContent); + }); + + await test.step('submit the form', async () => { + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + // Should redirect to list page after successful creation + await protosPom.isIndexPage(page); + }); + + await test.step('verify proto was created via API', async () => { + // Get the list of protos to find the created one + const protos = await e2eReq + .get(API_PROTOS) + .then((v) => v.data); + + // Find the proto with our content (search for exact package name) + const createdProto = protos.list.find((p) => + p.value.content?.includes('package test;') + ); + expect(createdProto).toBeDefined(); + expect(createdProto?.value.id).toBeDefined(); + // eslint-disable-next-line playwright/no-conditional-in-test + createdProtoId = createdProto?.value.id || ''; + + // Verify content matches + expect(createdProto?.value.content).toBe(protoContent); + }); + }); + + test('should read/view the proto details', async () => { + await test.step('verify proto can be retrieved via API', async () => { + const proto = await e2eReq + .get( + `${API_PROTOS}/${createdProtoId}` + ) + .then((v) => v.data); + + expect(proto.value?.id).toBe(createdProtoId); + expect(proto.value?.content).toBe(protoContent); + expect(proto.value?.create_time).toBeDefined(); + expect(proto.value?.update_time).toBeDefined(); + }); + }); + + test('should update the proto with new values', async () => { + const updatedContent = `syntax = "proto3"; +package test_updated; + +message UpdatedTestMessage { + string updated_name = 1; + int32 updated_age = 2; + string email = 3; + bool is_active = 4; +}`; + + await test.step('update proto via API', async () => { + await e2eReq.put(`${API_PROTOS}/${createdProtoId}`, { + content: updatedContent, + }); + }); + + await test.step('verify proto was updated via API', async () => { + const proto = await e2eReq + .get( + `${API_PROTOS}/${createdProtoId}` + ) + .then((v) => v.data); + + expect(proto.value?.id).toBe(createdProtoId); + expect(proto.value?.content).toBe(updatedContent); + }); + }); + + test('should delete the proto', async () => { + await test.step('delete proto via API', async () => { + await e2eReq.delete(`${API_PROTOS}/${createdProtoId}`); + }); + + await test.step('verify proto was deleted via API', async () => { + await expect(async () => { + await e2eReq.get(`${API_PROTOS}/${createdProtoId}`); + }).rejects.toThrow(); + }); + }); +}); diff --git a/e2e/tests/protos.crud-required-fields.spec.ts b/e2e/tests/protos.crud-required-fields.spec.ts new file mode 100644 index 000000000..b42a66627 --- /dev/null +++ b/e2e/tests/protos.crud-required-fields.spec.ts @@ -0,0 +1,140 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { protosPom } from '@e2e/pom/protos'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { expect } from '@playwright/test'; + +import { API_PROTOS } from '@/config/constant'; +import type { APISIXType } from '@/types/schema/apisix'; + +const protoContent = `syntax = "proto3"; +package test_required; + +message TestMessageRequired { + string name = 1; + int32 age = 2; +}`; + +let createdProtoId: string; + +test.describe('CRUD proto with required fields only', () => { + test.describe.configure({ mode: 'serial' }); + + test.afterAll(async () => { + // cleanup: delete the proto + if (createdProtoId) { + await e2eReq.delete(`${API_PROTOS}/${createdProtoId}`).catch(() => { + // ignore error if proto doesn't exist + }); + } + }); + + test('should create a proto with required fields', async ({ page }) => { + await test.step('navigate to add proto page', async () => { + await protosPom.toAdd(page); + await protosPom.isAddPage(page); + }); + + await test.step('fill in required fields', async () => { + // Fill Content (ID is auto-generated) + await page.getByLabel('Content').fill(protoContent); + }); + + await test.step('submit the form', async () => { + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + // Should redirect to list page after successful creation + await protosPom.isIndexPage(page); + }); + + await test.step('verify proto was created via API', async () => { + // Get the list of protos to find the created one + const protos = await e2eReq + .get(API_PROTOS) + .then((v) => v.data); + + // Find the proto with our content + const createdProto = protos.list.find((p) => + p.value.content?.includes('package test_required') + ); + expect(createdProto).toBeDefined(); + expect(createdProto?.value.id).toBeDefined(); + // eslint-disable-next-line playwright/no-conditional-in-test + createdProtoId = createdProto?.value.id || ''; + + // Verify content matches + expect(createdProto?.value.content).toBe(protoContent); + }); + }); + + test('should read/view the proto details', async () => { + await test.step('verify proto can be retrieved via API', async () => { + const proto = await e2eReq + .get( + `${API_PROTOS}/${createdProtoId}` + ) + .then((v) => v.data); + + expect(proto.value?.id).toBe(createdProtoId); + expect(proto.value?.content).toBe(protoContent); + expect(proto.value?.create_time).toBeDefined(); + expect(proto.value?.update_time).toBeDefined(); + }); + }); + + test('should update the proto', async () => { + const updatedContent = `syntax = "proto3"; +package test_updated; + +message UpdatedTestMessage { + string updated_name = 1; + int32 updated_age = 2; + string email = 3; +}`; + + await test.step('update proto via API', async () => { + await e2eReq.put(`${API_PROTOS}/${createdProtoId}`, { + content: updatedContent, + }); + }); + + await test.step('verify proto was updated via API', async () => { + const proto = await e2eReq + .get( + `${API_PROTOS}/${createdProtoId}` + ) + .then((v) => v.data); + + expect(proto.value?.id).toBe(createdProtoId); + expect(proto.value?.content).toBe(updatedContent); + }); + }); + + test('should delete the proto', async () => { + await test.step('delete proto via API', async () => { + await e2eReq.delete(`${API_PROTOS}/${createdProtoId}`); + }); + + await test.step('verify proto was deleted via API', async () => { + await expect(async () => { + await e2eReq.get(`${API_PROTOS}/${createdProtoId}`); + }).rejects.toThrow(); + }); + }); +}); diff --git a/e2e/tests/protos.list.spec.ts b/e2e/tests/protos.list.spec.ts new file mode 100644 index 000000000..2f17897e0 --- /dev/null +++ b/e2e/tests/protos.list.spec.ts @@ -0,0 +1,96 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { protosPom } from '@e2e/pom/protos'; +import { setupPaginationTests } from '@e2e/utils/pagination-test-helper'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { expect, type Page } from '@playwright/test'; + +import { putProtoReq } from '@/apis/protos'; +import { API_PROTOS } from '@/config/constant'; +import type { APISIXType } from '@/types/schema/apisix'; + +test('should navigate to protos page', async ({ page }) => { + await test.step('navigate to protos page', async () => { + await protosPom.getProtoNavBtn(page).click(); + await protosPom.isIndexPage(page); + }); + + await test.step('verify protos page components', async () => { + await expect(protosPom.getAddProtoBtn(page)).toBeVisible(); + + // list table exists + const table = page.getByRole('table'); + await expect(table).toBeVisible(); + await expect(table.getByText('ID', { exact: true })).toBeVisible(); + await expect(table.getByText('Actions', { exact: true })).toBeVisible(); + }); +}); + +const protos: APISIXType['Proto'][] = Array.from({ length: 11 }, (_, i) => ({ + id: `proto_id_${i + 1}`, + desc: `proto_desc_${i + 1}`, + content: `syntax = "proto3"; +package test${i + 1}; + +message TestMessage${i + 1} { + string field = 1; +}`, +})); + +test.describe('page and page_size should work correctly', () => { + test.describe.configure({ mode: 'serial' }); + test.beforeAll(async () => { + // Delete all existing protos + const existingProtos = await e2eReq + .get(API_PROTOS) + .then((v) => v.data); + await Promise.all( + (existingProtos.list || []).map((d) => + e2eReq.delete(`${API_PROTOS}/${d.value.id}`) + ) + ); + + // Create test protos + await Promise.all(protos.map((d) => putProtoReq(e2eReq, d))); + }); + + test.afterAll(async () => { + await Promise.all( + protos.map((d) => e2eReq.delete(`${API_PROTOS}/${d.id}`)) + ); + }); + + // Setup pagination tests with proto-specific configurations + const filterItemsNotInPage = async (page: Page) => { + // filter the item which not in the current page + // it should be random, so we need get all items in the table + const itemsInPage = await page + .getByRole('cell', { name: /proto_id_/ }) + .all(); + const ids = await Promise.all(itemsInPage.map((v) => v.textContent())); + return protos.filter((d) => !ids.includes(d.id)); + }; + + setupPaginationTests(test, { + pom: protosPom, + items: protos, + filterItemsNotInPage, + getCell: (page, item) => page.getByRole('cell', { name: item.id }).first(), + }); +});