diff --git a/frontend/src/app/modules/policy-engine/policy-engine.module.ts b/frontend/src/app/modules/policy-engine/policy-engine.module.ts index e946655e85..1292bf6a50 100644 --- a/frontend/src/app/modules/policy-engine/policy-engine.module.ts +++ b/frontend/src/app/modules/policy-engine/policy-engine.module.ts @@ -181,6 +181,7 @@ import { ChangeBlockSettingsDialog } from './dialogs/change-block-settings-dialo import { ApproveUpdateVcDocumentDialogComponent } from './dialogs/approve-update-vc-document-dialog/approve-update-vc-document-dialog.component' import { AddDocumentDialog } from './dialogs/add-document-dialog/add-document-dialog.component'; import { MockDialog } from './dialogs/mock-dialog/mock-dialog.component'; +import { PolicyTestAutomationPopupComponent } from './policy-viewer/policy-test-automation/policy-test-automation-popup.component'; @NgModule({ declarations: [ @@ -318,7 +319,8 @@ import { MockDialog } from './dialogs/mock-dialog/mock-dialog.component'; AddDocumentDialog, ChangeBlockSettingsDialog, ApproveUpdateVcDocumentDialogComponent, - MockDialog + MockDialog, + PolicyTestAutomationPopupComponent ], imports: [ CommonModule, diff --git a/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block-addon/request-document-block-addon.component.ts b/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block-addon/request-document-block-addon.component.ts index 555b2d0afa..2e58f0c21e 100644 --- a/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block-addon/request-document-block-addon.component.ts +++ b/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block-addon/request-document-block-addon.component.ts @@ -19,6 +19,7 @@ import { RequestDocumentBlockDialog } from '../request-document-block/dialog/req import { SchemaRulesService } from 'src/app/services/schema-rules.service'; import { prepareVcData } from 'src/app/modules/common/models/prepare-vc-data'; import { PolicyStatus } from '@guardian/interfaces'; +import { PolicyTestAutomationDraftService } from '../../policy-test-automation/policy-test-automation-draft.service'; interface IRequestDocumentAddonData { readonly: boolean; @@ -92,7 +93,8 @@ export class RequestDocumentBlockAddonComponent private fb: UntypedFormBuilder, private dialogService: DialogService, private router: Router, - private changeDetectorRef: ChangeDetectorRef + private changeDetectorRef: ChangeDetectorRef, + private policyTestDraft: PolicyTestAutomationDraftService ) { super(policyEngineService, profile, wsService); this.dataForm = this.fb.group({}); @@ -203,14 +205,25 @@ export class RequestDocumentBlockAddonComponent if (this.dataForm.valid) { const data = this.dataForm.getRawValue(); prepareVcData(data); + const payload = { + document: data, + ref: this.ref?.id, + }; + + if (this.dryRun && this.policyTestDraft.draft.captureNextFormSubmit) { + this.policyTestDraft.captureInput({ + policyId: this.policyId, + blockId: this.id, + blockType: 'requestDocumentBlockAddon', + ...payload + }); + } + this.dialogRef.close(); this.dialogRef = null; this.loading = true; this.policyEngineService - .setBlockData(this.id, this.policyId, { - document: data, - ref: this.ref?.id, - }) + .setBlockData(this.id, this.policyId, payload) .subscribe( // tslint:disable-next-line:no-empty () => { }, diff --git a/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block/dialog/request-document-block-dialog.component.ts b/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block/dialog/request-document-block-dialog.component.ts index 085f48534a..cbfc0f156f 100644 --- a/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block/dialog/request-document-block-dialog.component.ts +++ b/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block/dialog/request-document-block-dialog.component.ts @@ -18,6 +18,7 @@ import { autosaveValueChanged, getMinutesAgoStream } from 'src/app/utils/autosav import { RelayerAccountsService } from 'src/app/services/relayer-accounts.service'; import { AttachedFile } from 'src/app/modules/common/policy-comments/attached-file'; import { IPFSService } from 'src/app/services/ipfs.service'; +import { PolicyTestAutomationDraftService } from '../../../policy-test-automation/policy-test-automation-draft.service'; @Component({ selector: 'request-document-block-dialog', @@ -115,6 +116,7 @@ export class RequestDocumentBlockDialog { private indexedDb: IndexedDbRegistryService, private tablePersist: TablePersistenceService, private ipfsService: IPFSService, + private policyTestDraft: PolicyTestAutomationDraftService, ) { this.parent = this.config.data; this.dataForm = this.fb.group({}); @@ -294,15 +296,26 @@ export class RequestDocumentBlockDialog { const evidence = this.enableAdditionalData ? this.buildEvidence() : undefined; + const payload = { + document: data, + ref: this.docRef, + draft, + draftId, + relayerAccount: this.getRelayerAccount(), + ...(evidence?.length ? { evidence } : {}) + }; + + if (this.dryRun && !draft && this.policyTestDraft.draft.captureNextFormSubmit) { + this.policyTestDraft.captureInput({ + policyId: this.policyId, + blockId: this.id, + blockType: 'requestDocumentBlock', + ...payload + }); + } + this.policyEngineService - .setBlockData(this.id, this.policyId, { - document: data, - ref: this.docRef, - draft: draft, - draftId: draftId, - relayerAccount: this.getRelayerAccount(), - ...(evidence?.length ? { evidence } : {}) - }) + .setBlockData(this.id, this.policyId, payload) .subscribe(() => { setTimeout(() => { this.loading = false; diff --git a/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block/request-document-block.component.ts b/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block/request-document-block.component.ts index 9d5cc1b32e..779d481aa1 100644 --- a/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block/request-document-block.component.ts +++ b/frontend/src/app/modules/policy-engine/policy-viewer/blocks/request-document-block/request-document-block.component.ts @@ -23,6 +23,7 @@ import { PolicyStatus } from '@guardian/interfaces'; import { RelayerAccountsService } from 'src/app/services/relayer-accounts.service'; import { AttachedFile } from 'src/app/modules/common/policy-comments/attached-file'; import { IPFSService } from 'src/app/services/ipfs.service'; +import { PolicyTestAutomationDraftService } from '../../policy-test-automation/policy-test-automation-draft.service'; interface IRequestDocumentData { readonly: boolean; @@ -150,6 +151,7 @@ export class RequestDocumentBlockComponent private indexedDb: IndexedDbRegistryService, private tablePersist: TablePersistenceService, private ipfsService: IPFSService, + private policyTestDraft: PolicyTestAutomationDraftService, ) { super(policyEngineService, profile, wsService); this.dataForm = this.fb.group({}); @@ -419,17 +421,28 @@ export class RequestDocumentBlockComponent const evidence = this.enableAdditionalData ? this.buildEvidence() : undefined; + const payload = { + document: data, + ref: this.ref, + draft, + draftId: this.draftId, + relayerAccount: this.getRelayerAccount(), + ...(evidence?.length ? { evidence } : {}) + }; + + if (this.dryRun && !draft && this.policyTestDraft.draft.captureNextFormSubmit) { + this.policyTestDraft.captureInput({ + policyId: this.policyId, + blockId: this.id, + blockType: 'requestDocumentBlock', + ...payload + }); + } + let requestSucceeded = false; this.policyEngineService - .setBlockData(this.id, this.policyId, { - document: data, - ref: this.ref, - draft, - draftId: this.draftId, - relayerAccount: this.getRelayerAccount(), - ...(evidence?.length ? { evidence } : {}) - }) + .setBlockData(this.id, this.policyId, payload) .pipe( finalize(async () => { try { diff --git a/frontend/src/app/modules/policy-engine/policy-viewer/policy-test-automation/policy-test-automation-draft.service.ts b/frontend/src/app/modules/policy-engine/policy-viewer/policy-test-automation/policy-test-automation-draft.service.ts new file mode 100644 index 0000000000..d353a9ee36 --- /dev/null +++ b/frontend/src/app/modules/policy-engine/policy-viewer/policy-test-automation/policy-test-automation-draft.service.ts @@ -0,0 +1,188 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +export interface PolicyTestInputAnchor { + policyId: string; + blockId: string; + blockType?: string; + title?: string; + ref?: unknown; + document: unknown; + draft?: boolean; + draftId?: string | null; + relayerAccount?: unknown; + evidence?: { dataType: 'message' | 'file'; data: string }[]; + capturedAt: string; +} + +export interface PolicyTestOutputAnchor { + type: 'vc' | 'vp' | 'schema' | string; + id: string; + title?: string; + document?: unknown; + source?: { + policyId?: string; + documentId?: string; + schemaId?: string; + messageId?: string; + rowId?: string; + blockId?: string; + blockType?: string; + inputCapturedAt?: string; + }; + capturedAt: string; +} + +export interface PolicyTestAutomationDraft { + captureNextFormSubmit: boolean; + input: PolicyTestInputAnchor | null; + outputs: PolicyTestOutputAnchor[]; + name: string; + description: string; + readyToSave: boolean; +} + +const createInitialDraft = (): PolicyTestAutomationDraft => ({ + captureNextFormSubmit: false, + input: null, + outputs: [], + name: '', + description: '', + readyToSave: false +}); + +@Injectable({ providedIn: 'root' }) +export class PolicyTestAutomationDraftService { + private readonly draftSubject = new BehaviorSubject(createInitialDraft()); + public readonly draft$ = this.draftSubject.asObservable(); + + public get draft(): PolicyTestAutomationDraft { + return this.draftSubject.value; + } + + public setCaptureNextFormSubmit(value: boolean): void { + const draft = this.draft; + if (value && draft.input) { + return; + } + this.update({ captureNextFormSubmit: value }); + } + + public captureInput(input: Omit): void { + if (this.draft.input) { + return; + } + this.update({ + captureNextFormSubmit: false, + input: { + ...this.clone(input), + capturedAt: new Date().toISOString() + } + }); + } + + public discardInput(): void { + this.update({ input: null, captureNextFormSubmit: false }); + } + + public addOutput(output: Omit): void { + const exists = this.draft.outputs.some((item) => { + return item.type === output.type && item.id === output.id; + }); + if (exists) { + return; + } + this.update({ + outputs: [ + ...this.draft.outputs, + { ...this.clone(output), capturedAt: new Date().toISOString() } + ], + readyToSave: false + }); + } + + public discardOutput(type: string, id: string): void { + this.update({ + outputs: this.draft.outputs.filter((item) => item.type !== type || item.id !== id), + readyToSave: false + }); + } + + public confirmOutputFromInput(): void { + const input = this.draft.input; + if (!input) { + return; + } + this.addOutput({ + type: input.blockType || 'input', + id: [ + input.policyId, + input.blockId, + input.capturedAt + ].join(':'), + title: input.title || 'Confirmed output', + document: input.document, + source: { + policyId: input.policyId, + blockId: input.blockId, + blockType: input.blockType, + inputCapturedAt: input.capturedAt + } + }); + this.update({ + input: null, + captureNextFormSubmit: false, + readyToSave: false + }); + } + + public setMetadata(name: string, description: string): void { + this.update({ name, description }); + } + + public markReadyToSave(): boolean { + const readyToSave = !!this.draft.input && this.draft.outputs.length > 0; + this.update({ readyToSave }); + return readyToSave; + } + + public hasInput(): boolean { + return !!this.draft.input; + } + + public hasOutputs(): boolean { + return this.draft.outputs.length > 0; + } + + public shouldWarnBeforeStop(): boolean { + return this.hasInput() && !this.hasOutputs(); + } + + public getRecordMetadata(): { version: 1; name?: string; description?: string; input?: PolicyTestInputAnchor; outputs?: PolicyTestOutputAnchor[] } | null { + if (!this.draft.input || !this.draft.outputs.length) { + return null; + } + return { + version: 1, + name: this.draft.name || undefined, + description: this.draft.description || undefined, + input: this.draft.input, + outputs: this.draft.outputs + }; + } + + public reset(): void { + this.draftSubject.next(createInitialDraft()); + } + + private update(patch: Partial): void { + this.draftSubject.next({ + ...this.draft, + ...patch + }); + } + + private clone(value: T): T { + return JSON.parse(JSON.stringify(value)); + } +} diff --git a/frontend/src/app/modules/policy-engine/policy-viewer/policy-test-automation/policy-test-automation-popup.component.html b/frontend/src/app/modules/policy-engine/policy-viewer/policy-test-automation/policy-test-automation-popup.component.html new file mode 100644 index 0000000000..b6ed31cecc --- /dev/null +++ b/frontend/src/app/modules/policy-engine/policy-viewer/policy-test-automation/policy-test-automation-popup.component.html @@ -0,0 +1,75 @@ +
+
+
Policy Test Automation
+ + + + +
+ +
+
Input
+ +
+
+ + +
+ {{ draft.input.title || 'Input captured' }} + + +
+
+ +
+
+ + Data on next form submission will be used for test input +
+
+
+
+ +
+
Outputs
+ +
+
+
Confirm Document Results
+ +
+ {{ output.title || output.type }} + + +
+
No outputs yet
+
+ +
+
+ + Review Document output and confirm they are correct +
+
+
+
+
diff --git a/frontend/src/app/modules/policy-engine/policy-viewer/policy-test-automation/policy-test-automation-popup.component.scss b/frontend/src/app/modules/policy-engine/policy-viewer/policy-test-automation/policy-test-automation-popup.component.scss new file mode 100644 index 0000000000..1043772508 --- /dev/null +++ b/frontend/src/app/modules/policy-engine/policy-viewer/policy-test-automation/policy-test-automation-popup.component.scss @@ -0,0 +1,167 @@ +.policy-test-automation-popup { + width: 480px; + padding: 16px; + color: #23252e; + font-size: 13px; + border: 1px solid #999; + border-radius: 6px; + background: #d9d9d9; +} + +.policy-test-automation-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.policy-test-automation-title { + font-weight: 700; + font-size: 14px; +} + +.policy-test-section { + margin-bottom: 16px; +} + +.policy-test-section-title { + font-weight: 700; + margin-bottom: 8px; + border-bottom: 1px solid #333; + padding-bottom: 2px; + display: inline-block; + text-decoration: underline; +} + +.policy-test-section-body { + display: grid; + grid-template-columns: auto 1fr; + gap: 16px; + align-items: start; +} + +.policy-test-section-left { + display: flex; + flex-direction: column; + gap: 8px; +} + +.policy-test-section-right { + display: flex; + flex-direction: column; + gap: 8px; +} + +.policy-test-section-subtitle { + font-style: italic; + color: #666; + font-size: 12px; +} + +.policy-test-checkbox-row { + display: flex; + align-items: center; + gap: 8px; + font-style: italic; +} + +.policy-test-checkbox-row input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; +} + +.policy-test-section-disabled { + opacity: 0.5; +} + +.policy-test-muted { + color: #888; + font-size: 12px; + font-style: italic; +} + +.policy-test-help { + display: flex; + align-items: flex-start; + gap: 8px; + background: #e8f5e9; + border: 1px solid #a5d6a7; + padding: 8px 10px; + border-radius: 4px; + font-size: 11px; + color: #2d5016; + + i { + margin-top: 1px; + flex-shrink: 0; + color: #2d5016; + font-size: 14px; + } + + span { + line-height: 1.4; + } +} + +.policy-test-status-button { + padding: 8px 18px; + border: 1px solid #999; + background: #b8b8b8; + color: #fff; + font-size: 12px; + font-weight: 700; + border-radius: 3px; + cursor: default; + letter-spacing: 0.5px; + white-space: nowrap; + + &:disabled { + opacity: 1; + cursor: not-allowed; + } +} + +.policy-test-status-button-active { + border: 1px solid #0089d0; + background: #0089d0; + color: white; + cursor: pointer; + + &:hover { + opacity: 0.9; + } + + &:active { + opacity: 0.8; + } +} + +.policy-test-draft-item { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 12px; + align-items: center; + font-size: 12px; +} + +.policy-test-draft-item button { + border: 0; + background: none; + display: flex; + align-items: center; + gap: 4px; + color: #333; + cursor: pointer; + padding: 0; + font-size: 12px; + + i { + font-size: 14px; + } + + &:hover { + opacity: 0.7; + } +} diff --git a/frontend/src/app/modules/policy-engine/policy-viewer/policy-test-automation/policy-test-automation-popup.component.ts b/frontend/src/app/modules/policy-engine/policy-viewer/policy-test-automation/policy-test-automation-popup.component.ts new file mode 100644 index 0000000000..7939dcacc9 --- /dev/null +++ b/frontend/src/app/modules/policy-engine/policy-viewer/policy-test-automation/policy-test-automation-popup.component.ts @@ -0,0 +1,58 @@ +import { Component } from '@angular/core'; +import { DialogService } from 'primeng/dynamicdialog'; +import { ViewerDialog } from '../../dialogs/viewer-dialog/viewer-dialog.component'; +import { PolicyTestAutomationDraftService, PolicyTestOutputAnchor } from './policy-test-automation-draft.service'; + +@Component({ + selector: 'policy-test-automation-popup', + templateUrl: './policy-test-automation-popup.component.html', + styleUrls: ['./policy-test-automation-popup.component.scss'] +}) +export class PolicyTestAutomationPopupComponent { + constructor( + public draftService: PolicyTestAutomationDraftService, + private dialogService: DialogService + ) {} + + public onCaptureChange(checked: boolean): void { + this.draftService.setCaptureNextFormSubmit(checked); + } + + public discardInput(): void { + this.draftService.discardInput(); + } + + public onAwaitingOutputsClick(): void { + this.draftService.confirmOutputFromInput(); + } + + public inspectInput(): void { + const input = this.draftService.draft.input; + if (!input) { + return; + } + this.openJson(input.title || 'Input', input.document); + } + + public inspectOutput(output: PolicyTestOutputAnchor): void { + this.openJson(output.title || 'Output', output.document || output); + } + + public discardOutput(type: string, id: string): void { + this.draftService.discardOutput(type, id); + } + + private openJson(title: string, value: unknown): void { + this.dialogService.open(ViewerDialog, { + showHeader: false, + width: '90%', + styleClass: 'guardian-dialog', + data: { + title, + type: 'JSON', + value, + dryRun: true + } + }); + } +} diff --git a/frontend/src/app/modules/policy-engine/record/record-controller/record-controller.component.html b/frontend/src/app/modules/policy-engine/record/record-controller/record-controller.component.html index 367aa873ea..6bf7a8cb53 100644 --- a/frontend/src/app/modules/policy-engine/record/record-controller/record-controller.component.html +++ b/frontend/src/app/modules/policy-engine/record/record-controller/record-controller.component.html @@ -10,11 +10,12 @@
REC
-
- +
+ TEST +
-
- +
+ STOP
@@ -36,6 +37,10 @@
+ + + + diff --git a/frontend/src/app/modules/policy-engine/record/record-controller/record-controller.component.scss b/frontend/src/app/modules/policy-engine/record/record-controller/record-controller.component.scss index 60c40c6f14..20d295fbae 100644 --- a/frontend/src/app/modules/policy-engine/record/record-controller/record-controller.component.scss +++ b/frontend/src/app/modules/policy-engine/record/record-controller/record-controller.component.scss @@ -266,4 +266,45 @@ top: 0; z-index: 1; pointer-events: all; +} + +.recording-test-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + color: #646464; + + span { + line-height: 1; + } + + i { + font-size: 10px; + } +} + +.recording-stop-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + color: #f00; + + span { + line-height: 1; + } +} + +::ng-deep .policy-test-record-overlay { + .p-overlayPanel-content { + padding: 0; + border: none; + box-shadow: none; + background: transparent; + } } \ No newline at end of file diff --git a/frontend/src/app/modules/policy-engine/record/record-controller/record-controller.component.ts b/frontend/src/app/modules/policy-engine/record/record-controller/record-controller.component.ts index 1e1f261f4f..c8d3a01483 100644 --- a/frontend/src/app/modules/policy-engine/record/record-controller/record-controller.component.ts +++ b/frontend/src/app/modules/policy-engine/record/record-controller/record-controller.component.ts @@ -11,6 +11,7 @@ import { ImportEntityDialog, ImportEntityType } from 'src/app/modules/common/import-entity-dialog/import-entity-dialog.component'; +import {PolicyTestAutomationDraftService} from '../../policy-viewer/policy-test-automation/policy-test-automation-draft.service'; @Component({ selector: 'app-record-controller', @@ -47,6 +48,7 @@ export class RecordControllerComponent implements OnInit { private recordService: RecordService, private router: Router, private dialog: DialogService, + private policyTestDraft: PolicyTestAutomationDraftService, ) { this._showActions = (localStorage.getItem('SHOW_RECORD_ACTIONS') || 'true') === 'true'; this._overlay = localStorage.getItem('HIDE_RECORD_OVERLAY'); @@ -121,6 +123,38 @@ export class RecordControllerComponent implements OnInit { } public stopRecording() { + if (this.policyTestDraft.shouldWarnBeforeStop()) { + this.openNoOutputWarning(); + return; + } + + this.stopRecordingInternal(); + } + + private openNoOutputWarning(): void { + const dialogRef = this.dialog.open(ConfirmDialog, { + width: '560px', + data: { + title: 'Stop and Discard Input?', + description: [ + 'Input test data was not captured or there is no corresponding output document(s) selected.', + 'Stop this recording and discard input test data?' + ], + submitButton: 'Confirm', + cancelButton: 'Cancel' + }, + modal: true, + closable: false + }); + + dialogRef.onClose.subscribe((confirmed: boolean) => { + if (confirmed) { + this.stopRecordingInternal(); + } + }); + } + + private stopRecordingInternal(): void { this.loading = true; this.recordItems = []; this.recordService.stopRecording(this.policyId).subscribe((fileBuffer) => {