blob: dd6085d387f5596abf7b1df356fd311828fe4197 [file]
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../test/common-test-setup';
import './gr-flows';
import {assert, fixture, html} from '@open-wc/testing';
import {GrFlows} from './gr-flows';
import {
AccountId,
CommitId,
FlowInfo,
FlowStageState,
Timestamp,
} from '../../../api/rest-api';
import {queryAndAssert} from '../../../test/test-utils';
import {NumericChangeId} from '../../../types/common';
import sinon from 'sinon';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {FlowsModel, flowsModelToken} from '../../../models/flows/flows-model';
import {
ChangeModel,
changeModelToken,
} from '../../../models/change/change-model';
import {UserModel, userModelToken} from '../../../models/user/user-model';
import {testResolver} from '../../../test/common-test-setup';
import {
createAccountDetailWithId,
createFlow,
createParsedChange,
createRevision,
} from '../../../test/test-data-generators';
function setChangeWithOwner(changeModel: ChangeModel, ownerId: AccountId) {
changeModel.updateState({
change: {
...createParsedChange(),
_number: 123 as NumericChangeId,
owner: createAccountDetailWithId(ownerId),
revisions: {
rev1: {
...createRevision(1),
uploader: createAccountDetailWithId(ownerId),
},
},
current_revision: 'rev1' as CommitId,
},
});
}
suite('gr-flows tests', () => {
let element: GrFlows;
let clock: sinon.SinonFakeTimers;
let flowsModel: FlowsModel;
let changeModel: ChangeModel;
let userModel: UserModel;
setup(async () => {
clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
changeModel = testResolver(changeModelToken);
userModel = testResolver(userModelToken);
flowsModel = testResolver(flowsModelToken);
// The model is created by the DI system. The test setup replaces the real
// model with a mock. To prevent real API calls, we stub the reload method.
sinon.stub(flowsModel, 'reload');
element = await fixture<GrFlows>(html`<gr-flows></gr-flows>`);
await element.updateComplete;
setChangeWithOwner(changeModel, 123 as AccountId);
userModel.setState({
account: createAccountDetailWithId(123 as AccountId),
accountLoaded: true,
});
await element.updateComplete;
});
teardown(() => {
clock.restore();
});
test('renders create flow component and no flows', async () => {
flowsModel.setState({
flows: [],
loading: false,
isEnabled: true,
providers: [],
autosubmitProviders: [],
});
await element.updateComplete;
});
test('renders flows', async () => {
const flows: FlowInfo[] = [
createFlow({
last_evaluated: '2025-01-01T11:00:00.000Z' as Timestamp,
stages: [
{
expression: {
condition: 'label:Code-Review=+1',
},
state: FlowStageState.PENDING,
},
],
}),
createFlow({
uuid: 'flow2',
owner: {name: 'owner2', _account_id: 2 as AccountId},
created: '2025-01-02T10:00:00.000Z' as Timestamp,
stages: [
{
expression: {
condition: 'label:Verified=+1',
action: {name: 'submit'},
},
state: FlowStageState.PENDING,
},
],
}),
];
flowsModel.setState({
flows,
loading: false,
isEnabled: true,
providers: [],
autosubmitProviders: [],
});
await element.updateComplete;
// prettier formats the spacing for "last evaluated" incorrectly
assert.shadowDom.equal(
element,
/* HTML */ `
<div class="container">
<div class="header-actions">
<gr-create-flow> </gr-create-flow>
</div>
<div class="flows-header">
<div class="heading-with-button">
<h2 class="main-heading">Scheduled Flows</h2>
<gr-button
aria-disabled="false"
aria-label="Refresh flows"
class="refresh"
link=""
role="button"
tabindex="0"
title="Refresh flows"
>
<gr-icon icon="refresh"> </gr-icon>
</gr-button>
</div>
</div>
<div>
<div class="flow">
<div class="flow-header">
<div class="flow-title">Flow</div>
<div class="flow-actions">
<gr-copy-clipboard
buttontitle="Copy flow string to clipboard"
hideinput=""
>
</gr-copy-clipboard>
<gr-button
aria-disabled="false"
link=""
role="button"
tabindex="0"
title="Delete flow"
>
<gr-icon icon="delete"> </gr-icon>
</gr-button>
</div>
</div>
<div class="flow-info">
<div class="owner-container">
Owner:
<gr-avatar hidden=""> </gr-avatar>
<gr-account-label deselected=""> </gr-account-label>
</div>
<div>
Last Evaluation:
<gr-date-formatter withtooltip=""> </gr-date-formatter>
</div>
</div>
<div class="stages">
<gr-flow-rule></gr-flow-rule>
</div>
</div>
<div class="flow">
<div class="flow-header">
<div class="flow-title">Submit</div>
<div class="flow-actions">
<gr-copy-clipboard
buttontitle="Copy flow string to clipboard"
hideinput=""
>
</gr-copy-clipboard>
<gr-button
aria-disabled="false"
link=""
role="button"
tabindex="0"
title="Delete flow"
>
<gr-icon icon="delete"> </gr-icon>
</gr-button>
</div>
</div>
<div class="flow-info">
<div class="owner-container">
Owner:
<gr-avatar hidden=""> </gr-avatar>
<gr-account-label deselected=""> </gr-account-label>
</div>
</div>
<div class="stages">
<gr-flow-rule></gr-flow-rule>
</div>
</div>
</div>
</div>
<dialog id="deleteFlowModal">
<gr-dialog confirm-label="Delete" role="dialog">
<div class="header" slot="header">Delete Flow</div>
<div class="main" slot="main">
Are you sure you want to delete this flow?
</div>
</gr-dialog>
</dialog>
`
);
});
test('disables delete button for successful flows', async () => {
const flows: FlowInfo[] = [
createFlow({
stages: [
{
expression: {
condition: 'label:Verified=+1',
action: {name: 'submit'},
},
state: FlowStageState.DONE,
},
],
}),
];
flowsModel.setState({
flows,
loading: false,
isEnabled: true,
providers: [],
autosubmitProviders: [],
});
await element.updateComplete;
const deleteButton = queryAndAssert<GrButton>(
element,
'gr-button[title="Delete flow"]'
);
assert.isTrue(deleteButton.disabled);
});
test('does not disable delete button for pending flows', async () => {
const flows: FlowInfo[] = [
createFlow({
stages: [
{
expression: {
condition: 'label:Verified=+1',
action: {name: 'submit'},
},
state: FlowStageState.PENDING,
},
],
}),
];
flowsModel.setState({
flows,
loading: false,
isEnabled: true,
providers: [],
autosubmitProviders: [],
});
await element.updateComplete;
const deleteButton = queryAndAssert<GrButton>(
element,
'.flow .flow-actions gr-button[title="Delete flow"]'
);
assert.isFalse(deleteButton.disabled);
});
test('deletes a flow after confirmation', async () => {
const flows: FlowInfo[] = [
createFlow({
stages: [
{
expression: {
condition: 'label:Code-Review=+1',
},
state: FlowStageState.PENDING,
},
],
}),
];
const deleteFlowStub = sinon.stub(flowsModel, 'deleteFlow');
flowsModel.setState({
flows,
loading: false,
isEnabled: true,
providers: [],
autosubmitProviders: [],
});
await element.updateComplete;
const deleteButton = queryAndAssert<GrButton>(
element,
'gr-button[title="Delete flow"]'
);
deleteButton.click();
await element.updateComplete;
const dialog = queryAndAssert<HTMLDialogElement>(
element,
'#deleteFlowModal'
);
assert.isTrue(dialog.open);
const grDialog = queryAndAssert<GrDialog>(dialog, 'gr-dialog');
const confirmButton = queryAndAssert<GrButton>(grDialog, '#confirm');
confirmButton.click();
await element.updateComplete;
assert.isTrue(deleteFlowStub.calledOnceWith('flow1'));
});
test('cancel deleting a flow', async () => {
const flows: FlowInfo[] = [
createFlow({
stages: [
{
expression: {
condition: 'label:Code-Review=+1',
},
state: FlowStageState.PENDING,
},
],
}),
];
const deleteFlowStub = sinon.stub(flowsModel, 'deleteFlow');
flowsModel.setState({
flows,
loading: false,
isEnabled: true,
providers: [],
autosubmitProviders: [],
});
await element.updateComplete;
const deleteButton = queryAndAssert<GrButton>(
element,
'gr-button[title="Delete flow"]'
);
deleteButton.click();
await element.updateComplete;
const dialog = queryAndAssert<HTMLDialogElement>(
element,
'#deleteFlowModal'
);
assert.isTrue(dialog.open);
const grDialog = queryAndAssert<GrDialog>(dialog, 'gr-dialog');
const cancelButton = queryAndAssert<GrButton>(grDialog, '#cancel');
cancelButton.click();
await element.updateComplete;
assert.isTrue(deleteFlowStub.notCalled);
assert.isFalse(dialog.open);
});
test('refreshes flows on button click', async () => {
const flow = createFlow({
stages: [
{
expression: {
condition: 'label:Code-Review=+1',
},
state: FlowStageState.PENDING,
},
],
});
flowsModel.setState({
flows: [flow],
loading: false,
isEnabled: true,
providers: [],
autosubmitProviders: [],
});
await element.updateComplete;
const reloadStub = flowsModel.reload as sinon.SinonStub;
reloadStub.resetHistory();
const refreshButton = queryAndAssert<GrButton>(
element,
'.flows-header gr-button'
);
refreshButton.click();
await element.updateComplete;
assert.isTrue(reloadStub.calledOnce);
});
suite('create flow visibility', () => {
setup(async () => {
flowsModel.setState({
flows: [],
loading: false,
isEnabled: true,
providers: [],
autosubmitProviders: [],
});
await element.updateComplete;
});
test('shows gr-create-flow when current user is owner', async () => {
const ownerId = 123 as AccountId;
const currentUserId = 123 as AccountId;
setChangeWithOwner(changeModel, ownerId);
userModel.setState({
account: createAccountDetailWithId(currentUserId),
accountLoaded: true,
});
await element.updateComplete;
const createFlow = element.shadowRoot!.querySelector('gr-create-flow');
assert.isNotNull(createFlow);
});
test('hides gr-create-flow when current user is not owner', async () => {
const ownerId = 456 as AccountId;
const currentUserId = 123 as AccountId;
setChangeWithOwner(changeModel, ownerId);
userModel.setState({
account: createAccountDetailWithId(currentUserId),
accountLoaded: true,
});
await element.updateComplete;
const createFlow = element.shadowRoot!.querySelector('gr-create-flow');
assert.isNull(createFlow);
});
});
});