From e7e03329da71a1fe9339826ca8fa8474acf844b3 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 08:23:08 +0100 Subject: [PATCH 01/18] Repo save file fix --- .../vml/es/acm/core/repo/RepoResource.java | 47 +++++++------------ .../dev/vml/es/acm/core/script/Script.java | 2 +- .../example/ACME-202_page-thumbnail.groovy | 1 - ui.frontend/src/components/PathPicker.tsx | 2 +- 4 files changed, 19 insertions(+), 33 deletions(-) diff --git a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java index 98ffc441..a4f433f1 100644 --- a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java +++ b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java @@ -558,39 +558,26 @@ public RepoResource saveFile(String mimeType, Object data) { private void saveFileInternal(String mimeType, Object data, Consumer dataWriter) { Resource mainResource = resolve(); try { - if (mainResource == null) { - String parentPath = StringUtils.substringBeforeLast(path, "/"); - Resource parent = repo.getResourceResolver().getResource(parentPath); - if (parent == null) { - throw new RepoException( - String.format("Cannot save file as parent path '%s' does not exist!", parentPath)); - } - String name = StringUtils.substringAfterLast(path, "/"); - Map mainValues = new HashMap<>(); - mainValues.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_FILE); - mainResource = repo.getResourceResolver().create(parent, name, mainValues); + if (mainResource != null) { + repo.getResourceResolver().delete(mainResource); + } - Map contentValues = new HashMap<>(); - setFileContent(contentValues, mimeType, data, dataWriter); - repo.getResourceResolver().create(mainResource, JcrConstants.JCR_CONTENT, contentValues); + Resource parent = parent().resolve(); + if (parent == null) { + throw new RepoException( + String.format("Cannot save file as parent path '%s' does not exist!", parentPath())); + } + String name = getName(); + Map mainValues = new HashMap<>(); + mainValues.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_FILE); + mainResource = repo.getResourceResolver().create(parent, name, mainValues); - repo.commit(String.format("creating file at path '%s'", path)); - repo.getLogger().info("Created file at path '{}'", path); - } else { - Resource contentResource = mainResource.getChild(JcrConstants.JCR_CONTENT); - if (contentResource == null) { - Map contentValues = new HashMap<>(); - setFileContent(contentValues, mimeType, data, dataWriter); - repo.getResourceResolver().create(mainResource, JcrConstants.JCR_CONTENT, contentValues); - } else { - ModifiableValueMap contentValues = - Objects.requireNonNull(contentResource.adaptTo(ModifiableValueMap.class)); - setFileContent(contentValues, mimeType, data, dataWriter); - } + Map contentValues = new HashMap<>(); + setFileContent(contentValues, mimeType, data, dataWriter); + repo.getResourceResolver().create(mainResource, JcrConstants.JCR_CONTENT, contentValues); - repo.commit(String.format("updating file at path '%s'", path)); - repo.getLogger().info("Updated file at path '{}'", path); - } + repo.commit(String.format("saving file at path '%s'", path)); + repo.getLogger().info("Saved file at path '{}'", path); } catch (PersistenceException e) { throw new RepoException(String.format("Cannot save file at path '%s'!", path), e); } diff --git a/core/src/main/java/dev/vml/es/acm/core/script/Script.java b/core/src/main/java/dev/vml/es/acm/core/script/Script.java index 0d7e94d8..5b0042f3 100644 --- a/core/src/main/java/dev/vml/es/acm/core/script/Script.java +++ b/core/src/main/java/dev/vml/es/acm/core/script/Script.java @@ -2,8 +2,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import dev.vml.es.acm.core.AcmException; -import dev.vml.es.acm.core.code.ExecutableMetadata; import dev.vml.es.acm.core.code.Executable; +import dev.vml.es.acm.core.code.ExecutableMetadata; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-202_page-thumbnail.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-202_page-thumbnail.groovy index b663e5e7..99565ba1 100644 --- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-202_page-thumbnail.groovy +++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-202_page-thumbnail.groovy @@ -21,7 +21,6 @@ void doRun() { def page = repo.get(inputs.value("pagePath")) try { def pageImage = page.child("jcr:content/image").ensure("nt:unstructured") - pageImage.child("file/jcr:content/dam:thumbnails").delete() pageImage.child("file").saveFile("image/jpeg", pageThumnail.readFileAsStream()) } finally { pageThumnail.delete() diff --git a/ui.frontend/src/components/PathPicker.tsx b/ui.frontend/src/components/PathPicker.tsx index b3ad5b31..5679e60e 100644 --- a/ui.frontend/src/components/PathPicker.tsx +++ b/ui.frontend/src/components/PathPicker.tsx @@ -338,7 +338,7 @@ const PathField = forwardRef(({ root, onSele return ( - + From c8571171e777fd185d2d28cab0b2270bd5d0caa6 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 08:38:12 +0100 Subject: [PATCH 02/18] Minor --- .../settings/script/manual/example/ACME-203_output-xls.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-xls.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-xls.groovy index ec6f92c5..891c4c42 100644 --- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-xls.groovy +++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-xls.groovy @@ -25,7 +25,7 @@ graph TD */ void describeRun() { - inputs.integerNumber("count") { label = "Users to generate"; min = 1; value = 100000 } + inputs.integerNumber("count") { label = "Users to generate"; min = 1; value = 10000 } inputs.text("firstNames") { label = "First names"; description = "One first name per line"; value = "John\nJane\nJack\nAlice\nBob"} inputs.text("lastNames") { label = "Last names"; description = "One last name per line"; value = "Doe\nSmith\nBrown\nJohnson\nWhite" } } From d8a5a727f5b30627666722b1acd273f670dbec41 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 10:03:09 +0100 Subject: [PATCH 03/18] Hardening --- .../vml/es/acm/core/repo/RepoResource.java | 86 ++++++++++--------- .../es/acm/core/script/ScriptRepository.java | 8 +- .../src/components/ExecutableMetadata.tsx | 2 +- 3 files changed, 51 insertions(+), 45 deletions(-) diff --git a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java index a4f433f1..c2684df1 100644 --- a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java +++ b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java @@ -3,7 +3,6 @@ import dev.vml.es.acm.core.util.*; import java.io.*; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.util.*; import java.util.concurrent.Executors; import java.util.function.Consumer; @@ -35,6 +34,8 @@ */ public class RepoResource { + public static final String FILE_MIME_TYPE_DEFAULT = "application/octet-stream"; + private final Repo repo; private final String path; @@ -534,28 +535,37 @@ public RepoResourceState state() { return new RepoResourceState(path, true, resource.getValueMap()); } - public RepoResource saveFile(String mimeType, Consumer dataWriter) { - saveFileInternal(mimeType, null, dataWriter); - return this; + public RepoResource saveFile(InputStream data) { + return saveFile(FILE_MIME_TYPE_DEFAULT, data); } - public RepoResource saveFile(String mimeType, File file) { - saveFile(mimeType, (OutputStream os) -> { - try (InputStream is = Files.newInputStream(file.toPath())) { - IOUtils.copy(is, os); - } catch (IOException e) { - throw new RepoException(String.format("Cannot write file '%s' to path '%s'!", file.getPath(), path), e); - } - }); + public RepoResource saveFile(String mimeType, InputStream data) { + return saveFile(Collections.singletonMap(JcrConstants.JCR_MIMETYPE, mimeType), data); + } + + public RepoResource saveFile(Map properties, InputStream data) { + saveFileInternal(properties, data, null); return this; } - public RepoResource saveFile(String mimeType, Object data) { - saveFileInternal(mimeType, data, null); + public RepoResource saveFile(Consumer dataWriter) { + return saveFile(FILE_MIME_TYPE_DEFAULT, dataWriter); + } + + public RepoResource saveFile(String mimeType, Consumer dataWriter) { + return saveFile(Collections.singletonMap(JcrConstants.JCR_MIMETYPE, mimeType), dataWriter); + } + + public RepoResource saveFile(Map properties, Consumer dataWriter) { + saveFileInternal(properties, null, dataWriter); return this; } - private void saveFileInternal(String mimeType, Object data, Consumer dataWriter) { + private void saveFileInternal(Map properties, InputStream data, Consumer dataWriter) { + if (properties == null || !properties.containsKey(JcrConstants.JCR_MIMETYPE)) { + properties = Collections.singletonMap(JcrConstants.JCR_MIMETYPE, FILE_MIME_TYPE_DEFAULT); + } + Resource mainResource = resolve(); try { if (mainResource != null) { @@ -573,7 +583,7 @@ private void saveFileInternal(String mimeType, Object data, Consumer contentValues = new HashMap<>(); - setFileContent(contentValues, mimeType, data, dataWriter); + setFileContent(contentValues, properties, data, dataWriter); repo.getResourceResolver().create(mainResource, JcrConstants.JCR_CONTENT, contentValues); repo.commit(String.format("saving file at path '%s'", path)); @@ -584,35 +594,27 @@ private void saveFileInternal(String mimeType, Object data, Consumer contentValues, String mimeType, Object data, Consumer dataWriter) { - if (dataWriter != null) { - setFileContent(contentValues, mimeType, dataWriter); - } else { - setFileContent(contentValues, mimeType, data); - } - } - - private void setFileContent(Map contentValues, String mimeType, Object data) { + Map contentValues, + Map props, + InputStream data, + Consumer dataWriter) { contentValues.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_RESOURCE); contentValues.put(JcrConstants.JCR_ENCODING, "utf-8"); - contentValues.put(JcrConstants.JCR_MIMETYPE, mimeType); - contentValues.put(JcrConstants.JCR_DATA, data); - } + contentValues.putAll(props); - // https://stackoverflow.com/a/27172165 - private void setFileContent(Map contentValues, String mimeType, Consumer dataWriter) { - contentValues.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_RESOURCE); - contentValues.put(JcrConstants.JCR_ENCODING, "utf-8"); - contentValues.put(JcrConstants.JCR_MIMETYPE, mimeType); - final PipedInputStream pis = new PipedInputStream(); - Executors.newSingleThreadExecutor().submit(() -> { - try (PipedOutputStream pos = new PipedOutputStream(pis)) { - dataWriter.accept(pos); - } catch (Exception e) { - throw new RepoException(String.format("Cannot write data to file at path '%s'!", path), e); - } - }); - contentValues.put(JcrConstants.JCR_DATA, pis); + if (dataWriter != null) { + final PipedInputStream pis = new PipedInputStream(); + Executors.newSingleThreadExecutor().submit(() -> { + try (PipedOutputStream pos = new PipedOutputStream(pis)) { + dataWriter.accept(pos); + } catch (Exception e) { + throw new RepoException(String.format("Cannot write data to file at path '%s'!", path), e); + } + }); + contentValues.put(JcrConstants.JCR_DATA, pis); + } else { + contentValues.put(JcrConstants.JCR_DATA, data); + } } public InputStream readFileAsStream() { diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java index 18cafc6c..a6b7e7e8 100644 --- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java +++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java @@ -7,6 +7,10 @@ import dev.vml.es.acm.core.repo.RepoException; import dev.vml.es.acm.core.repo.RepoResource; import dev.vml.es.acm.core.util.ResourceSpliterator; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Optional; import java.util.stream.Stream; @@ -55,10 +59,10 @@ private Resource getRoot(ScriptType type) throws RepoException { } public Script save(Code code) { - return save(code.getId(), code.getContent()); + return save(code.getId(), new ByteArrayInputStream(code.getContent().getBytes(StandardCharsets.UTF_8))); } - public Script save(String id, Object data) throws AcmException { + public Script save(String id, InputStream data) throws AcmException { if (!ScriptType.byPath(id).isPresent()) { throw new AcmException(String.format("Cannot save script '%s' at unsupported path!", id)); } diff --git a/ui.frontend/src/components/ExecutableMetadata.tsx b/ui.frontend/src/components/ExecutableMetadata.tsx index be2a9e66..792f5e25 100644 --- a/ui.frontend/src/components/ExecutableMetadata.tsx +++ b/ui.frontend/src/components/ExecutableMetadata.tsx @@ -3,9 +3,9 @@ import { Field } from '@react-spectrum/label'; import DataUnavailable from '@spectrum-icons/workflow/DataUnavailable'; import React from 'react'; import { ExecutableMetadata as ExecutableMetadataType } from '../types/executable'; +import { Strings } from '../utils/strings'; import Markdown from './Markdown'; import SnippetCode from './SnippetCode'; -import { Strings } from '../utils/strings'; type ExecutableMetadataProps = { metadata: ExecutableMetadataType | null | undefined; From be4fa43cdf472a1f68f875e56e9aa8f8650f9009 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 10:12:14 +0100 Subject: [PATCH 04/18] Snippets fix --- ui.frontend/src/components/ExecutableMetadata.tsx | 1 + ui.frontend/src/components/ExecutionInputs.tsx | 1 + ui.frontend/src/components/ExecutionOutputs.tsx | 1 + ui.frontend/src/components/SnippetCode.css | 7 +++++-- ui.frontend/src/components/SnippetCode.tsx | 5 +++-- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/ui.frontend/src/components/ExecutableMetadata.tsx b/ui.frontend/src/components/ExecutableMetadata.tsx index 792f5e25..5fa07918 100644 --- a/ui.frontend/src/components/ExecutableMetadata.tsx +++ b/ui.frontend/src/components/ExecutableMetadata.tsx @@ -40,6 +40,7 @@ const ExecutableMetadata: React.FC = ({ metadata }) => = ({ inputs }) => { = ({ outputs }) => { = ({ content, language, fontSize = 'medium' }) => { +const SnippetCode: React.FC = ({ content, language, fontSize = 'medium', scrollable = false }) => { const codeRef = useRef(null); useEffect(() => { @@ -77,7 +78,7 @@ const SnippetCode: React.FC = ({ content, language, fontSize = }, [content, language, fontSize]); return ( -
+
{content}
); From fdbb1248c333a18ca9b563ccf7d32d57292d5adf Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 10:47:03 +0100 Subject: [PATCH 05/18] Disable Coral3 tooltip --- ui.frontend/src/App.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ui.frontend/src/App.css b/ui.frontend/src/App.css index b2d77194..682b3a92 100644 --- a/ui.frontend/src/App.css +++ b/ui.frontend/src/App.css @@ -7,6 +7,15 @@ input[type='search'] { box-sizing: border-box !important; } +/* Disable Coral UI 3 tooltips (native HTML5 validation) */ +.coral3-Tooltip, +coral-tooltip { + display: none !important; + visibility: hidden !important; + opacity: 0 !important; + pointer-events: none !important; +} + .coral3-Shell-header { z-index: 1; } From c0618bd2de6f105e78499ca9ff5bd8308fbaea89 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 12:28:31 +0100 Subject: [PATCH 06/18] Build fix / chunks with colon --- .../es/acm/core/script/ScriptRepository.java | 1 - test/e2e/005-manual-scripts.spec.ts | 43 +++++++++++++++++++ .../ExecutionReviewOutputsButton.tsx | 6 +-- ui.frontend/vite.config.ts | 6 +++ 4 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 test/e2e/005-manual-scripts.spec.ts diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java index a6b7e7e8..9e2af844 100644 --- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java +++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java @@ -7,7 +7,6 @@ import dev.vml.es.acm.core.repo.RepoException; import dev.vml.es.acm.core.repo.RepoResource; import dev.vml.es.acm.core.util.ResourceSpliterator; - import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.charset.StandardCharsets; diff --git a/test/e2e/005-manual-scripts.spec.ts b/test/e2e/005-manual-scripts.spec.ts new file mode 100644 index 00000000..305a41f3 --- /dev/null +++ b/test/e2e/005-manual-scripts.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test'; +import { expectExecutionProgressBarSucceeded } from './utils/expect'; +import { readFromCodeEditor } from './utils/editor'; + +test.describe('Manual Scripts', () => { + test('Execute CSV Generation With Inputs And Outputs', async ({ page }) => { + await page.goto('/acm'); + await page.getByRole('button', { name: 'Scripts' }).click(); + await page.getByText('example/ACME-203_output-csv').click(); + await page.getByRole('button', { name: 'Execute' }).click(); + + await page.getByRole('textbox', { name: 'Input \'count\' Users to' }).fill('5000'); + await page.getByRole('textbox', { name: 'Input \'firstNames\' First names' }).fill('John\nJane\nJack\nAlice\nBob\nRobert'); + await page.getByRole('textbox', { name: 'Input \'lastNames\' Last names' }).fill('Doe\nSmith\nBrown\nJohnson\nWhite\nJordan'); + + await page.getByRole('button', { name: 'Start' }).click(); + await expectExecutionProgressBarSucceeded(page); + + const output = await readFromCodeEditor(page); + expect(output).toContain('[SUCCESS] Users CSV report generation ended successfully'); + + await page.getByRole('button', { name: 'Review' }).click(); + await expect(page.getByText('Processed 5000 user(s)')).toBeVisible(); + await page.getByText('Files').click(); + + const downloadArchivePromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download Archive' }).click(); + const downloadArchive = await downloadArchivePromise; + expect(downloadArchive.suggestedFilename()).toMatch(/\.(zip|tar\.gz)$/); + + const downloadConsolePromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download Console' }).click(); + const downloadConsole = await downloadConsolePromise; + expect(downloadConsole.suggestedFilename()).toMatch(/\.txt$/); + + const downloadReportPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download Report' }).click(); + const downloadReport = await downloadReportPromise; + expect(downloadReport.suggestedFilename()).toMatch(/\.csv$/); + + await page.getByRole('button', { name: 'Close' }).click(); + }); +}); diff --git a/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx b/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx index 1ad3b0f8..9b35ad17 100644 --- a/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx +++ b/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx @@ -125,7 +125,7 @@ const ExecutionReviewOutputsButton: React.FC Archive Console and generated outputs bundled as ZIP archive - @@ -138,7 +138,7 @@ const ExecutionReviewOutputsButton: React.FC Console Execution logs and errors as text file - @@ -151,7 +151,7 @@ const ExecutionReviewOutputsButton: React.FC {outputFile.label || Strings.capitalizeWords(outputFile.name)} {outputFile.description && {outputFile.description}} - diff --git a/ui.frontend/vite.config.ts b/ui.frontend/vite.config.ts index deaa995e..65a59387 100644 --- a/ui.frontend/vite.config.ts +++ b/ui.frontend/vite.config.ts @@ -68,5 +68,11 @@ export default defineConfig({ build: { outDir: `../ui.apps/src/main/content/jcr_root${buildPath}`, emptyOutDir: true, + rollupOptions: { + output: { + // fixes cannot install package '*.zip'; unexpected status: javax.jcr.RepositoryException: OakName0001: Invalid namespace prefix([, basePickBy-CjSbM3k + inlineDynamicImports: true + }, + }, }, }); From a80929ae54d2e7a4233443670b726598c92e51e1 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 13:57:57 +0100 Subject: [PATCH 07/18] Manual test works --- test/e2e/005-manual-scripts.spec.ts | 14 ++++++++------ ui.frontend/vite.config.ts | 3 +-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/test/e2e/005-manual-scripts.spec.ts b/test/e2e/005-manual-scripts.spec.ts index 305a41f3..0c8ddfe8 100644 --- a/test/e2e/005-manual-scripts.spec.ts +++ b/test/e2e/005-manual-scripts.spec.ts @@ -3,7 +3,7 @@ import { expectExecutionProgressBarSucceeded } from './utils/expect'; import { readFromCodeEditor } from './utils/editor'; test.describe('Manual Scripts', () => { - test('Execute CSV Generation With Inputs And Outputs', async ({ page }) => { + test('Execute CSV Generation With I/O', async ({ page }) => { await page.goto('/acm'); await page.getByRole('button', { name: 'Scripts' }).click(); await page.getByText('example/ACME-203_output-csv').click(); @@ -21,23 +21,25 @@ test.describe('Manual Scripts', () => { await page.getByRole('button', { name: 'Review' }).click(); await expect(page.getByText('Processed 5000 user(s)')).toBeVisible(); - await page.getByText('Files').click(); + await page.getByRole('tab', { name: 'Files' }).click(); const downloadArchivePromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Download Archive' }).click(); const downloadArchive = await downloadArchivePromise; - expect(downloadArchive.suggestedFilename()).toMatch(/\.(zip|tar\.gz)$/); + expect(downloadArchive.suggestedFilename()).toMatch(/\.(zip)$/); + await page.getByRole('button', { name: 'Review' }).click(); + await page.getByRole('tab', { name: 'Files' }).click(); const downloadConsolePromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Download Console' }).click(); const downloadConsole = await downloadConsolePromise; - expect(downloadConsole.suggestedFilename()).toMatch(/\.txt$/); + expect(downloadConsole.suggestedFilename()).toMatch(/\.console\.log$/); + await page.getByRole('button', { name: 'Review' }).click(); + await page.getByRole('tab', { name: 'Files' }).click(); const downloadReportPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Download Report' }).click(); const downloadReport = await downloadReportPromise; expect(downloadReport.suggestedFilename()).toMatch(/\.csv$/); - - await page.getByRole('button', { name: 'Close' }).click(); }); }); diff --git a/ui.frontend/vite.config.ts b/ui.frontend/vite.config.ts index 65a59387..cb349486 100644 --- a/ui.frontend/vite.config.ts +++ b/ui.frontend/vite.config.ts @@ -70,8 +70,7 @@ export default defineConfig({ emptyOutDir: true, rollupOptions: { output: { - // fixes cannot install package '*.zip'; unexpected status: javax.jcr.RepositoryException: OakName0001: Invalid namespace prefix([, basePickBy-CjSbM3k - inlineDynamicImports: true + chunkFileNames: (chunkInfo) => `chunks/${chunkInfo.name.replace(/[:[\]@]/g, '-')}-[hash].js`, }, }, }, From 043d95986cd228160fcb34a2b3f5cbabca5b14fd Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 15:10:00 +0100 Subject: [PATCH 08/18] WiP --- test/e2e/002-console.spec.ts | 2 +- test/e2e/003-tool-access.spec.ts | 2 +- test/e2e/004-history.spec.ts | 4 +- test/e2e/005-manual-scripts.spec.ts | 50 +++++++++++++++++-- test/e2e/utils/editor.ts | 40 +++++++++------ test/e2e/utils/expect.ts | 4 ++ test/e2e/utils/labeledValue.ts | 13 +++++ .../src/components/ExecutionInputs.tsx | 2 +- .../src/components/ExecutionOutputs.tsx | 2 +- ui.frontend/src/components/InfoCard.tsx | 5 +- ui.frontend/src/pages/ConsolePage.tsx | 4 +- ui.frontend/src/pages/ExecutionView.tsx | 19 +++---- 12 files changed, 107 insertions(+), 40 deletions(-) create mode 100644 test/e2e/utils/labeledValue.ts diff --git a/test/e2e/002-console.spec.ts b/test/e2e/002-console.spec.ts index 2b074c10..1f84f746 100644 --- a/test/e2e/002-console.spec.ts +++ b/test/e2e/002-console.spec.ts @@ -25,7 +25,7 @@ test.describe('Console', () => { await page.getByRole('tab', { name: 'Output' }).click(); await expectExecutionProgressBarSucceeded(page); - const output = await readFromCodeEditor(page); + const output = await readFromCodeEditor(page, 'Console Output'); expectToHaveMultilineText(output, ` Hello World! `); diff --git a/test/e2e/003-tool-access.spec.ts b/test/e2e/003-tool-access.spec.ts index 73dec8ac..476d316f 100644 --- a/test/e2e/003-tool-access.spec.ts +++ b/test/e2e/003-tool-access.spec.ts @@ -63,7 +63,7 @@ test.describe('Tool Access', () => { await page.getByRole('tab', { name: 'Output' }).click(); await expectExecutionProgressBarSucceeded(page); - const output = await readFromCodeEditor(page); + const output = await readFromCodeEditor(page, 'Console Output'); expect(output).toContain('Setup complete!'); await newAemContext(browser, 'acm-test-user', 'test1234', async (testUserPage) => { diff --git a/test/e2e/004-history.spec.ts b/test/e2e/004-history.spec.ts index bb739e75..a8709c57 100644 --- a/test/e2e/004-history.spec.ts +++ b/test/e2e/004-history.spec.ts @@ -17,7 +17,7 @@ test.describe('History', () => { await expect(firstRow.locator('[role="rowheader"]')).toContainText('Console'); await firstRow.click(); await page.getByRole('tab', { name: 'Output' }).click(); - const firstOutput = await readFromCodeEditor(page); + const firstOutput = await readFromCodeEditor(page, 'Execution Output'); expect(firstOutput).toContain('Setup complete!'); await page.goto('/acm#/history'); @@ -27,7 +27,7 @@ test.describe('History', () => { await expect(secondRow.locator('[role="rowheader"]')).toContainText('Console'); await secondRow.click(); await page.getByRole('tab', { name: 'Output' }).click(); - const secondOutput = await readFromCodeEditor(page); + const secondOutput = await readFromCodeEditor(page, 'Execution Output'); expect(secondOutput).toContain('Hello World!'); }); }); diff --git a/test/e2e/005-manual-scripts.spec.ts b/test/e2e/005-manual-scripts.spec.ts index 0c8ddfe8..16ee83a1 100644 --- a/test/e2e/005-manual-scripts.spec.ts +++ b/test/e2e/005-manual-scripts.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@playwright/test'; -import { expectExecutionProgressBarSucceeded } from './utils/expect'; -import { readFromCodeEditor } from './utils/editor'; +import { expectExecutionProgressBarSucceeded, expectToMatchTimestamp } from './utils/expect'; +import { readFromCodeEditor, readFromCodeEditorAsJson } from './utils/editor'; +import { getLabeledValueText } from './utils/labeledValue'; test.describe('Manual Scripts', () => { test('Execute CSV Generation With I/O', async ({ page }) => { @@ -16,7 +17,7 @@ test.describe('Manual Scripts', () => { await page.getByRole('button', { name: 'Start' }).click(); await expectExecutionProgressBarSucceeded(page); - const output = await readFromCodeEditor(page); + const output = await readFromCodeEditor(page, 'Execution Output'); expect(output).toContain('[SUCCESS] Users CSV report generation ended successfully'); await page.getByRole('button', { name: 'Review' }).click(); @@ -41,5 +42,48 @@ test.describe('Manual Scripts', () => { await page.getByRole('button', { name: 'Download Report' }).click(); const downloadReport = await downloadReportPromise; expect(downloadReport.suggestedFilename()).toMatch(/\.csv$/); + + + await page.getByRole('tab', { name: 'Details' }).click(); + + const executionStatus = page.locator('#execution-status'); + const executionId = await getLabeledValueText(page, 'ID', executionStatus); + expect(executionId).toMatch(/^2025\/\d+\/\d+\/\d+\/\d+\//); + + await expect(page.getByText('admin')).toBeVisible(); + await expect(page.getByText('succeeded', { exact: false })).toBeVisible(); + + const executionTiming = page.locator('#execution-timing'); + const startedAt = await getLabeledValueText(page, 'Started At', executionTiming); + expectToMatchTimestamp(startedAt); + + const duration = await getLabeledValueText(page, 'Duration', executionTiming); + expect(duration).toMatch(/\d+ ms \(\d+ seconds?\)/); + + const endedAt = await getLabeledValueText(page, 'Ended At', executionTiming); + expectToMatchTimestamp(endedAt); + + const inputs = await readFromCodeEditorAsJson>(page, 'Execution Inputs JSON'); + expect(inputs).toEqual({ + count: 5000, + firstNames: 'John\nJane\nJack\nAlice\nBob\nRobert', + lastNames: 'Doe\nSmith\nBrown\nJohnson\nWhite\nJordan', + }); + + const outputs = await readFromCodeEditorAsJson>(page, 'Execution Outputs JSON'); + expect(outputs).toHaveLength(2); + expect(outputs[0]).toMatchObject({ + type: 'FILE', + name: 'report', + label: 'Report', + downloadName: 'report.csv', + }); + expect(outputs[1]).toMatchObject({ + type: 'TEXT', + name: 'summary', + value: 'Processed 5000 user(s)', + }); + + await page.getByRole('button', { name: 'Close' }).click(); }); }); diff --git a/test/e2e/utils/editor.ts b/test/e2e/utils/editor.ts index 7c23d109..f67e6b04 100644 --- a/test/e2e/utils/editor.ts +++ b/test/e2e/utils/editor.ts @@ -22,24 +22,32 @@ export async function writeToCodeEditor(page: Page, code: string, editorIndex: n }, { editorIndex, codeClean }); } -export async function readFromCodeEditor(page: Page, editorIndex: number = 0): Promise { - await page.waitForFunction((data: { editorIndex: number }) => { +export async function readFromCodeEditor(page: Page, ariaLabel: string): Promise { + await page.waitForFunction((label: string) => { const editors = (window as any).monaco?.editor?.getEditors?.() || []; - if (editors.length <= data.editorIndex) { - return false; - } - - const editor = editors[data.editorIndex]; - const model = editor.getModel(); - const value = model ? model.getValue() : ''; - - return value.trim().length > 0; - }, { editorIndex }, { timeout: 10000 }); + return editors.some((editor: any) => { + const domNode = editor.getDomNode(); + const textarea = domNode?.querySelector('textarea'); + return textarea?.getAttribute('aria-label') === label; + }); + }, ariaLabel, { timeout: 10000 }); - return await page.evaluate((data: { editorIndex: number }) => { + return await page.evaluate((label: string) => { const editors = (window as any).monaco?.editor?.getEditors?.() || []; - const editor = editors[data.editorIndex]; - const model = editor.getModel(); + const editor = editors.find((e: any) => { + const domNode = e.getDomNode(); + const textarea = domNode?.querySelector('textarea'); + return textarea?.getAttribute('aria-label') === label; + }); + const model = editor?.getModel(); return model ? model.getValue() : ''; - }, { editorIndex }); + }, ariaLabel); +} + +export async function readFromCodeEditorAsJson( + page: Page, + ariaLabel: string +): Promise { + const content = await readFromCodeEditor(page, ariaLabel); + return JSON.parse(content); } diff --git a/test/e2e/utils/expect.ts b/test/e2e/utils/expect.ts index 4dbd9ca5..e7ad4e16 100644 --- a/test/e2e/utils/expect.ts +++ b/test/e2e/utils/expect.ts @@ -28,4 +28,8 @@ export function expectToHaveMultilineText(actual: string, expected: string) { for (const line of lines) { expect(actual).toContain(line); } +} + +export function expectToMatchTimestamp(actual: string) { + expect(actual).toMatch(/\d+ \w+ \d{4} at \d+:\d+( \(.+\))?/); } \ No newline at end of file diff --git a/test/e2e/utils/labeledValue.ts b/test/e2e/utils/labeledValue.ts new file mode 100644 index 00000000..779fcb9a --- /dev/null +++ b/test/e2e/utils/labeledValue.ts @@ -0,0 +1,13 @@ +import { Locator, Page } from '@playwright/test'; + +/** + * Gets the value text from a LabeledValue by its label + */ +export async function getLabeledValueText(page: Page, label: string, scope?: Locator): Promise { + const container = scope || page; + const labeledValue = container.locator('div[class*="LabeledValue"]').filter({ + has: container.locator('span[class*="FieldLabel"]:text-is("' + label + '")') + }); + const valueSpan = labeledValue.locator('span[class*="Field-field"]').first(); + return await valueSpan.textContent() || ''; +} diff --git a/ui.frontend/src/components/ExecutionInputs.tsx b/ui.frontend/src/components/ExecutionInputs.tsx index 7a1785c3..726565df 100644 --- a/ui.frontend/src/components/ExecutionInputs.tsx +++ b/ui.frontend/src/components/ExecutionInputs.tsx @@ -58,7 +58,7 @@ const ExecutionInputs: React.FC = ({ inputs }) => { ) : ( - + )}
diff --git a/ui.frontend/src/components/ExecutionOutputs.tsx b/ui.frontend/src/components/ExecutionOutputs.tsx index 6e775478..3fc14d63 100644 --- a/ui.frontend/src/components/ExecutionOutputs.tsx +++ b/ui.frontend/src/components/ExecutionOutputs.tsx @@ -58,7 +58,7 @@ const ExecutionOutputs: React.FC = ({ outputs }) => {
) : ( - + )} diff --git a/ui.frontend/src/components/InfoCard.tsx b/ui.frontend/src/components/InfoCard.tsx index f8fc2f0a..a7da57c2 100644 --- a/ui.frontend/src/components/InfoCard.tsx +++ b/ui.frontend/src/components/InfoCard.tsx @@ -3,11 +3,12 @@ import { ReactNode } from 'react'; type InfoCardProps = { children: ReactNode; + id?: string; }; -const InfoCard = ({ children }: InfoCardProps) => { +const InfoCard = ({ children, id }: InfoCardProps) => { return ( - + {children} diff --git a/ui.frontend/src/pages/ConsolePage.tsx b/ui.frontend/src/pages/ConsolePage.tsx index 48f3112d..708bc3cc 100644 --- a/ui.frontend/src/pages/ConsolePage.tsx +++ b/ui.frontend/src/pages/ConsolePage.tsx @@ -152,7 +152,7 @@ const ConsolePage = () => { - + @@ -179,7 +179,7 @@ const ConsolePage = () => { - + diff --git a/ui.frontend/src/pages/ExecutionView.tsx b/ui.frontend/src/pages/ExecutionView.tsx index 74991f43..f10ecfba 100644 --- a/ui.frontend/src/pages/ExecutionView.tsx +++ b/ui.frontend/src/pages/ExecutionView.tsx @@ -102,9 +102,8 @@ const ExecutionView = () => { - {/* Row 1: Execution Info */} - +
@@ -117,24 +116,22 @@ const ExecutionView = () => {
- +
- {/* Row 2: I/O */} - + - + - {/* Row 3: Executable Info */} - +
@@ -142,7 +139,7 @@ const ExecutionView = () => { {!isExecutableConsole(execution.executable.id) && } - + @@ -160,7 +157,7 @@ const ExecutionView = () => { - + @@ -186,7 +183,7 @@ const ExecutionView = () => { - + From ae2fda97a02781005ff8ae4c2d79a12d5eac09da Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 15:16:37 +0100 Subject: [PATCH 09/18] Manual green --- test/e2e/005-manual-scripts.spec.ts | 4 +--- test/e2e/utils/labeledValue.ts | 6 ++---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/test/e2e/005-manual-scripts.spec.ts b/test/e2e/005-manual-scripts.spec.ts index 16ee83a1..52bca61c 100644 --- a/test/e2e/005-manual-scripts.spec.ts +++ b/test/e2e/005-manual-scripts.spec.ts @@ -51,7 +51,7 @@ test.describe('Manual Scripts', () => { expect(executionId).toMatch(/^2025\/\d+\/\d+\/\d+\/\d+\//); await expect(page.getByText('admin')).toBeVisible(); - await expect(page.getByText('succeeded', { exact: false })).toBeVisible(); + await expect(executionStatus.getByRole('presentation').filter({ hasText: 'succeeded' })).toBeVisible(); const executionTiming = page.locator('#execution-timing'); const startedAt = await getLabeledValueText(page, 'Started At', executionTiming); @@ -83,7 +83,5 @@ test.describe('Manual Scripts', () => { name: 'summary', value: 'Processed 5000 user(s)', }); - - await page.getByRole('button', { name: 'Close' }).click(); }); }); diff --git a/test/e2e/utils/labeledValue.ts b/test/e2e/utils/labeledValue.ts index 779fcb9a..95843247 100644 --- a/test/e2e/utils/labeledValue.ts +++ b/test/e2e/utils/labeledValue.ts @@ -5,9 +5,7 @@ import { Locator, Page } from '@playwright/test'; */ export async function getLabeledValueText(page: Page, label: string, scope?: Locator): Promise { const container = scope || page; - const labeledValue = container.locator('div[class*="LabeledValue"]').filter({ - has: container.locator('span[class*="FieldLabel"]:text-is("' + label + '")') - }); - const valueSpan = labeledValue.locator('span[class*="Field-field"]').first(); + const labelSpan = container.locator('span[class*="FieldLabel"]:text-is("' + label + '")'); + const valueSpan = labelSpan.locator('..').locator('span[class*="Field-field"]'); return await valueSpan.textContent() || ''; } From 1ba8f5d11276f0b3d1fb9606c0d04baec0c93ebb Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 15:21:03 +0100 Subject: [PATCH 10/18] Refactor --- test/e2e/005-manual-scripts.spec.ts | 48 ++++++++++++++--------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/test/e2e/005-manual-scripts.spec.ts b/test/e2e/005-manual-scripts.spec.ts index 52bca61c..069786bb 100644 --- a/test/e2e/005-manual-scripts.spec.ts +++ b/test/e2e/005-manual-scripts.spec.ts @@ -20,30 +20,6 @@ test.describe('Manual Scripts', () => { const output = await readFromCodeEditor(page, 'Execution Output'); expect(output).toContain('[SUCCESS] Users CSV report generation ended successfully'); - await page.getByRole('button', { name: 'Review' }).click(); - await expect(page.getByText('Processed 5000 user(s)')).toBeVisible(); - await page.getByRole('tab', { name: 'Files' }).click(); - - const downloadArchivePromise = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Download Archive' }).click(); - const downloadArchive = await downloadArchivePromise; - expect(downloadArchive.suggestedFilename()).toMatch(/\.(zip)$/); - - await page.getByRole('button', { name: 'Review' }).click(); - await page.getByRole('tab', { name: 'Files' }).click(); - const downloadConsolePromise = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Download Console' }).click(); - const downloadConsole = await downloadConsolePromise; - expect(downloadConsole.suggestedFilename()).toMatch(/\.console\.log$/); - - await page.getByRole('button', { name: 'Review' }).click(); - await page.getByRole('tab', { name: 'Files' }).click(); - const downloadReportPromise = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Download Report' }).click(); - const downloadReport = await downloadReportPromise; - expect(downloadReport.suggestedFilename()).toMatch(/\.csv$/); - - await page.getByRole('tab', { name: 'Details' }).click(); const executionStatus = page.locator('#execution-status'); @@ -83,5 +59,29 @@ test.describe('Manual Scripts', () => { name: 'summary', value: 'Processed 5000 user(s)', }); + + await page.getByRole('tab', { name: 'Output' }).click(); + await page.getByRole('button', { name: 'Review' }).click(); + await expect(page.getByText('Processed 5000 user(s)')).toBeVisible(); + await page.getByRole('tab', { name: 'Files' }).click(); + + const downloadArchivePromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download Archive' }).click(); + const downloadArchive = await downloadArchivePromise; + expect(downloadArchive.suggestedFilename()).toMatch(/\.(zip)$/); + + await page.getByRole('button', { name: 'Review' }).click(); + await page.getByRole('tab', { name: 'Files' }).click(); + const downloadConsolePromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download Console' }).click(); + const downloadConsole = await downloadConsolePromise; + expect(downloadConsole.suggestedFilename()).toMatch(/\.console\.log$/); + + await page.getByRole('button', { name: 'Review' }).click(); + await page.getByRole('tab', { name: 'Files' }).click(); + const downloadReportPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download Report' }).click(); + const downloadReport = await downloadReportPromise; + expect(downloadReport.suggestedFilename()).toMatch(/\.csv$/); }); }); From b5f257e1dfde4ec796556e765821ef4e07412608 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 21:19:31 +0100 Subject: [PATCH 11/18] Tests polished --- test/e2e/005-manual-scripts.spec.ts | 92 +++++++++++++---------------- test/e2e/utils/expect.ts | 55 +++++++++++++++++ test/e2e/utils/labeledValue.ts | 19 +++++- 3 files changed, 112 insertions(+), 54 deletions(-) diff --git a/test/e2e/005-manual-scripts.spec.ts b/test/e2e/005-manual-scripts.spec.ts index 069786bb..821a0c87 100644 --- a/test/e2e/005-manual-scripts.spec.ts +++ b/test/e2e/005-manual-scripts.spec.ts @@ -1,7 +1,14 @@ import { test, expect } from '@playwright/test'; -import { expectExecutionProgressBarSucceeded, expectToMatchTimestamp } from './utils/expect'; -import { readFromCodeEditor, readFromCodeEditorAsJson } from './utils/editor'; -import { getLabeledValueText } from './utils/labeledValue'; +import { + expectExecutionProgressBarSucceeded, + expectExecutionDetails, + expectExecutionTimings, + expectExecutionInputs, + expectExecutionOutputs, + expectOutputTexts, + expectOutputFileDownload, +} from './utils/expect'; +import { readFromCodeEditor } from './utils/editor'; test.describe('Manual Scripts', () => { test('Execute CSV Generation With I/O', async ({ page }) => { @@ -10,9 +17,9 @@ test.describe('Manual Scripts', () => { await page.getByText('example/ACME-203_output-csv').click(); await page.getByRole('button', { name: 'Execute' }).click(); - await page.getByRole('textbox', { name: 'Input \'count\' Users to' }).fill('5000'); - await page.getByRole('textbox', { name: 'Input \'firstNames\' First names' }).fill('John\nJane\nJack\nAlice\nBob\nRobert'); - await page.getByRole('textbox', { name: 'Input \'lastNames\' Last names' }).fill('Doe\nSmith\nBrown\nJohnson\nWhite\nJordan'); + await page.getByRole('textbox', { name: 'Users to' }).fill('5000'); + await page.getByRole('textbox', { name: 'First names' }).fill('John\nJane\nJack\nAlice\nBob\nRobert'); + await page.getByRole('textbox', { name: 'Last names' }).fill('Doe\nSmith\nBrown\nJohnson\nWhite\nJordan'); await page.getByRole('button', { name: 'Start' }).click(); await expectExecutionProgressBarSucceeded(page); @@ -21,67 +28,50 @@ test.describe('Manual Scripts', () => { expect(output).toContain('[SUCCESS] Users CSV report generation ended successfully'); await page.getByRole('tab', { name: 'Details' }).click(); + + await page.waitForTimeout(1000); + await page.screenshot(); - const executionStatus = page.locator('#execution-status'); - const executionId = await getLabeledValueText(page, 'ID', executionStatus); - expect(executionId).toMatch(/^2025\/\d+\/\d+\/\d+\/\d+\//); - - await expect(page.getByText('admin')).toBeVisible(); - await expect(executionStatus.getByRole('presentation').filter({ hasText: 'succeeded' })).toBeVisible(); - - const executionTiming = page.locator('#execution-timing'); - const startedAt = await getLabeledValueText(page, 'Started At', executionTiming); - expectToMatchTimestamp(startedAt); - - const duration = await getLabeledValueText(page, 'Duration', executionTiming); - expect(duration).toMatch(/\d+ ms \(\d+ seconds?\)/); - - const endedAt = await getLabeledValueText(page, 'Ended At', executionTiming); - expectToMatchTimestamp(endedAt); + await expectExecutionDetails(page); + await expectExecutionTimings(page); - const inputs = await readFromCodeEditorAsJson>(page, 'Execution Inputs JSON'); - expect(inputs).toEqual({ + await expectExecutionInputs(page, { count: 5000, firstNames: 'John\nJane\nJack\nAlice\nBob\nRobert', lastNames: 'Doe\nSmith\nBrown\nJohnson\nWhite\nJordan', }); - const outputs = await readFromCodeEditorAsJson>(page, 'Execution Outputs JSON'); - expect(outputs).toHaveLength(2); - expect(outputs[0]).toMatchObject({ - type: 'FILE', - name: 'report', - label: 'Report', - downloadName: 'report.csv', - }); - expect(outputs[1]).toMatchObject({ - type: 'TEXT', - name: 'summary', - value: 'Processed 5000 user(s)', - }); + await expectExecutionOutputs(page, [ + { + type: 'FILE', + name: 'report', + label: 'Report', + downloadName: 'report.csv', + }, + { + type: 'TEXT', + name: 'summary', + value: 'Processed 5000 user(s)', + }, + ]); await page.getByRole('tab', { name: 'Output' }).click(); + + await page.getByRole('button', { name: 'Review' }).click(); + await page.getByRole('tab', { name: 'Texts' }).click(); + await expectOutputTexts(page, ['Processed 5000 user(s)']); + await page.getByTestId('modal').getByRole('button', { name: 'Close' }).click(); + await page.getByRole('button', { name: 'Review' }).click(); - await expect(page.getByText('Processed 5000 user(s)')).toBeVisible(); await page.getByRole('tab', { name: 'Files' }).click(); - - const downloadArchivePromise = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Download Archive' }).click(); - const downloadArchive = await downloadArchivePromise; - expect(downloadArchive.suggestedFilename()).toMatch(/\.(zip)$/); + await expectOutputFileDownload(page, 'Download Archive', /\.(zip)$/); await page.getByRole('button', { name: 'Review' }).click(); await page.getByRole('tab', { name: 'Files' }).click(); - const downloadConsolePromise = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Download Console' }).click(); - const downloadConsole = await downloadConsolePromise; - expect(downloadConsole.suggestedFilename()).toMatch(/\.console\.log$/); + await expectOutputFileDownload(page, 'Download Console', /\.console\.log$/); await page.getByRole('button', { name: 'Review' }).click(); await page.getByRole('tab', { name: 'Files' }).click(); - const downloadReportPromise = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Download Report' }).click(); - const downloadReport = await downloadReportPromise; - expect(downloadReport.suggestedFilename()).toMatch(/\.csv$/); + await expectOutputFileDownload(page, 'Download Report', /\.csv$/); }); }); diff --git a/test/e2e/utils/expect.ts b/test/e2e/utils/expect.ts index e7ad4e16..7d9fea13 100644 --- a/test/e2e/utils/expect.ts +++ b/test/e2e/utils/expect.ts @@ -1,5 +1,7 @@ import { expect, Page } from '@playwright/test'; import { Strings } from './lang'; +import { readFromCodeEditorAsJson } from './editor'; +import { getLabeledValueText } from './labeledValue'; export async function expectCompilationSucceeded(page: Page) { await expect(async () => { @@ -32,4 +34,57 @@ export function expectToHaveMultilineText(actual: string, expected: string) { export function expectToMatchTimestamp(actual: string) { expect(actual).toMatch(/\d+ \w+ \d{4} at \d+:\d+( \(.+\))?/); +} + +export async function expectExecutionDetails(page: Page, options: { user?: string; status?: string } = {}) { + const { user = 'admin', status = 'succeeded' } = options; + + const executionStatus = page.locator('#execution-status'); + const executionId = await getLabeledValueText(page, 'ID', executionStatus); + expect(executionId).toMatch(/^\d{4}\/\d+\/\d+\/\d+\/\d+\//); + + const userName = await getLabeledValueText(page, 'User', executionStatus); + expect(userName).toBe(user); + + const statusBadge = await getLabeledValueText(page, 'Status', executionStatus); + expect(statusBadge.toLowerCase()).toBe(status.toLowerCase()); +} + +export async function expectExecutionTimings(page: Page) { + const executionTiming = page.locator('#execution-timing'); + + const startedAt = await getLabeledValueText(page, 'Started At', executionTiming); + expectToMatchTimestamp(startedAt); + + const duration = await getLabeledValueText(page, 'Duration', executionTiming); + expect(duration).toMatch(/\d+ ms \(\d+ seconds?\)/); + + const endedAt = await getLabeledValueText(page, 'Ended At', executionTiming); + expectToMatchTimestamp(endedAt); +} + +export async function expectExecutionInputs(page: Page, expectedInputs: Record) { + const inputs = await readFromCodeEditorAsJson>(page, 'Execution Inputs JSON'); + expect(inputs).toEqual(expectedInputs); +} + +export async function expectExecutionOutputs(page: Page, expectedOutputs: Array) { + const outputs = await readFromCodeEditorAsJson>(page, 'Execution Outputs JSON'); + expect(outputs).toHaveLength(expectedOutputs.length); + expectedOutputs.forEach((expected, index) => { + expect(outputs[index]).toMatchObject(expected); + }); +} + +export async function expectOutputTexts(page: Page, expectedTexts: string[]) { + for (const text of expectedTexts) { + await expect(page.getByText(text)).toBeVisible(); + } +} + +export async function expectOutputFileDownload(page: Page, buttonName: string, filenamePattern: RegExp) { + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: buttonName }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(filenamePattern); } \ No newline at end of file diff --git a/test/e2e/utils/labeledValue.ts b/test/e2e/utils/labeledValue.ts index 95843247..f2711ee5 100644 --- a/test/e2e/utils/labeledValue.ts +++ b/test/e2e/utils/labeledValue.ts @@ -2,10 +2,23 @@ import { Locator, Page } from '@playwright/test'; /** * Gets the value text from a LabeledValue by its label + * Works with various HTML structures by: + * 1. Finding any element containing the label text + * 2. Getting its parent container + * 3. Finding the first non-empty sibling with the value */ export async function getLabeledValueText(page: Page, label: string, scope?: Locator): Promise { const container = scope || page; - const labelSpan = container.locator('span[class*="FieldLabel"]:text-is("' + label + '")'); - const valueSpan = labelSpan.locator('..').locator('span[class*="Field-field"]'); - return await valueSpan.textContent() || ''; + + // Find the label element (could be label, span, or any element with the text) + const labelElement = container.locator(`:text-is("${label}")`).first(); + + // Get the parent container (the Field wrapper) + const fieldContainer = labelElement.locator('..'); + + // Get all text content from the container and remove the label text + const fullText = await fieldContainer.textContent() || ''; + const valueText = fullText.replace(label, '').trim(); + + return valueText; } From 77d41d36e196e6bfec34d960f3e15c2ab9c56aa7 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 21:35:49 +0100 Subject: [PATCH 12/18] Automatic script test --- test/e2e/004-history.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/e2e/004-history.spec.ts b/test/e2e/004-history.spec.ts index a8709c57..51adc6c1 100644 --- a/test/e2e/004-history.spec.ts +++ b/test/e2e/004-history.spec.ts @@ -30,4 +30,23 @@ test.describe('History', () => { const secondOutput = await readFromCodeEditor(page, 'Execution Output'); expect(secondOutput).toContain('Hello World!'); }); + + test('Shows automatic script executions', async ({ page }) => { + await page.goto('/acm'); + await page.getByRole('button', { name: 'History' }).click(); + + const grid = page.locator('[role="grid"][aria-label="Executions table"]'); + await expect(grid).toBeVisible(); + const rows = grid.locator('[role="row"]'); + + // Search for first automatic script + await page.getByRole('searchbox', { name: 'Executable' }).fill('example/ACME-20_once'); + await expect(rows.nth(1)).toContainText('Script \'example/ACME-20_once\''); + await expect(rows.nth(1)).toContainText('succeeded'); + + // Search for second automatic script + await page.getByRole('searchbox', { name: 'Executable' }).fill('example/ACME-21_changed'); + await expect(rows.nth(1)).toContainText('Script \'example/ACME-21_changed\''); + await expect(rows.nth(1)).toContainText('succeeded'); + }); }); From c732f2e6d23eb8173c4018891f6525e43c1f7d31 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 22:47:03 +0100 Subject: [PATCH 13/18] Test fix --- test/e2e/utils/context.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/e2e/utils/context.ts b/test/e2e/utils/context.ts index 156997b3..116d10b1 100644 --- a/test/e2e/utils/context.ts +++ b/test/e2e/utils/context.ts @@ -13,10 +13,12 @@ export async function newAemContext( }, }); + const page = await context.newPage(); + try { - const page = await context.newPage(); await callback(page); } finally { + await page.close(); await context.close(); } } From ce52ad727bd8be74fa917c93532b28e0b2b6a2db Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 22:59:29 +0100 Subject: [PATCH 14/18] Minor --- core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java index c2684df1..4b2cbaf6 100644 --- a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java +++ b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java @@ -599,7 +599,6 @@ private void setFileContent( InputStream data, Consumer dataWriter) { contentValues.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_RESOURCE); - contentValues.put(JcrConstants.JCR_ENCODING, "utf-8"); contentValues.putAll(props); if (dataWriter != null) { From ba6ded5825284b36c98678cc3983c192c99483c8 Mon Sep 17 00:00:00 2001 From: Krystian Panek <81212505+krystian-panek-vmltech@users.noreply.github.com> Date: Tue, 25 Nov 2025 23:05:08 +0100 Subject: [PATCH 15/18] Update core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../main/java/dev/vml/es/acm/core/repo/RepoResource.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java index 4b2cbaf6..8294ce9f 100644 --- a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java +++ b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java @@ -562,8 +562,13 @@ public RepoResource saveFile(Map properties, Consumer properties, InputStream data, Consumer dataWriter) { - if (properties == null || !properties.containsKey(JcrConstants.JCR_MIMETYPE)) { - properties = Collections.singletonMap(JcrConstants.JCR_MIMETYPE, FILE_MIME_TYPE_DEFAULT); + if (properties == null) { + properties = new HashMap<>(); + } + if (!properties.containsKey(JcrConstants.JCR_MIMETYPE)) { + Map mutableProps = new HashMap<>(properties); + mutableProps.put(JcrConstants.JCR_MIMETYPE, FILE_MIME_TYPE_DEFAULT); + properties = mutableProps; } Resource mainResource = resolve(); From 58afb361917a007cc73859b2064bc2f4e47737a3 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 23:06:21 +0100 Subject: [PATCH 16/18] Minor --- .../main/java/dev/vml/es/acm/core/repo/RepoResource.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java index 8294ce9f..31d0d5a1 100644 --- a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java +++ b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java @@ -566,9 +566,9 @@ private void saveFileInternal(Map properties, InputStream data, properties = new HashMap<>(); } if (!properties.containsKey(JcrConstants.JCR_MIMETYPE)) { - Map mutableProps = new HashMap<>(properties); - mutableProps.put(JcrConstants.JCR_MIMETYPE, FILE_MIME_TYPE_DEFAULT); - properties = mutableProps; + Map propertiesMutable = new HashMap<>(properties); + propertiesMutable.put(JcrConstants.JCR_MIMETYPE, FILE_MIME_TYPE_DEFAULT); + properties = propertiesMutable; } Resource mainResource = resolve(); From 9cfe51e1d97d60d6a282c6375434ca2bb81bc40b Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 23:19:22 +0100 Subject: [PATCH 17/18] Repo resource order before --- .../vml/es/acm/core/repo/RepoResource.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java index 31d0d5a1..fabff018 100644 --- a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java +++ b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java @@ -9,6 +9,7 @@ import java.util.function.Function; import java.util.stream.Stream; import javax.jcr.Node; +import javax.jcr.NodeIterator; import javax.jcr.Property; import javax.jcr.RepositoryException; import org.apache.commons.io.IOUtils; @@ -403,6 +404,50 @@ public RepoResource moveInPlace(RepoResource target, boolean replace) { return target; } + // AEM 6.5.0 has no 'resourceResolver.orderBefore' so adding own based on: + // https://github.com/apache/sling-org-apache-sling-jcr-resource/commit/3b8f01d226124417bdfdba4ca2086114a73a7c5d + public boolean orderBefore(String siblingName) { + Node parentNode = parent().requireNode(); + String name = getName(); + try { + long existingNodePosition = -1; + long siblingNodePosition = -1; + long index = 0; + + NodeIterator nodeIterator = parentNode.getNodes(); + while (nodeIterator.hasNext()) { + Node childNode = nodeIterator.nextNode(); + String childName = childNode.getName(); + + if (childName.equals(name)) { + existingNodePosition = index; + } + if (siblingName != null && childName.equals(siblingName)) { + siblingNodePosition = index; + } else if (siblingName == null && childName.equals(name)) { + if (existingNodePosition == nodeIterator.getSize() - 1) { + return false; + } + } + index++; + } + + if (siblingName != null + && existingNodePosition >= 0 + && siblingNodePosition >= 0 + && existingNodePosition == siblingNodePosition - 1) { + return false; + } + + parentNode.orderBefore(name, siblingName); + repo.commit(String.format("reordering resource '%s' before '%s'", path, siblingName)); + repo.getLogger().info("Reordered resource '{}' before '{}'", path, siblingName); + return true; + } catch (RepositoryException e) { + throw new RepoException(String.format("Cannot reorder resource '%s' before '%s'", path, siblingName), e); + } + } + public RepoResource parent() { String parentPath = parentPath(); if (parentPath == null) { From c3aedd59cb49d2ca2071a7146b3347b0afa33bac Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Tue, 25 Nov 2025 23:24:32 +0100 Subject: [PATCH 18/18] Minor --- test/e2e/004-history.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/e2e/004-history.spec.ts b/test/e2e/004-history.spec.ts index 51adc6c1..4936db50 100644 --- a/test/e2e/004-history.spec.ts +++ b/test/e2e/004-history.spec.ts @@ -39,12 +39,10 @@ test.describe('History', () => { await expect(grid).toBeVisible(); const rows = grid.locator('[role="row"]'); - // Search for first automatic script await page.getByRole('searchbox', { name: 'Executable' }).fill('example/ACME-20_once'); await expect(rows.nth(1)).toContainText('Script \'example/ACME-20_once\''); await expect(rows.nth(1)).toContainText('succeeded'); - // Search for second automatic script await page.getByRole('searchbox', { name: 'Executable' }).fill('example/ACME-21_changed'); await expect(rows.nth(1)).toContainText('Script \'example/ACME-21_changed\''); await expect(rows.nth(1)).toContainText('succeeded');