Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
### Prerequisites

- [Node.js](https://nodejs.org/) >= 22 (the project uses Node 24 via `.nvmrc`; [nvm](https://github.com/nvm-sh/nvm)'s `nvm use` reads it)
`.nvmrc` pins `24.19.0` because 24.20.0 and 24.21.0 fail `langium generate` with `ERR_INVALID_URL`; move it back to `24` once a later 24.x generates cleanly.
- [VS Code](https://code.visualstudio.com/) with the recommended extensions (VS Code will prompt you on first open)
- [Docker](https://www.docker.com/) for end-to-end tests (alternatives: [Podman](https://podman.io/), [Rancher Desktop](https://rancherdesktop.io/))

Expand Down
43 changes: 43 additions & 0 deletions packages/cli/test/build-parse.smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,49 @@ describe('bpmns build / bpmns parse: guards against destructive or unclear failu
line: `Error: ${dir} is a directory`,
}),
],
[
'build: a missing input is refused',
'build',
(dir) => {
const inputPath = path.join(dir, 'missing.bpmnscript');
return {
inputPath,
opts: {},
exit: 2,
line: `Error: file not found: ${inputPath}`,
};
},
],
[
'parse: an unreadable input is refused',
'parse',
(dir) => {
const { inputPath } = writeInput(dir, 'parse');
fs.chmodSync(inputPath, 0o000);
return {
inputPath,
opts: {},
exit: 2,
line: `Error: could not read ${inputPath}: EACCES: permission denied, open '${inputPath}'`,
};
},
],
[
'build: an unwritable output is refused',
'build',
(dir) => {
const { inputPath } = writeInput(dir, 'build');
const readOnly = path.join(dir, 'read-only');
fs.mkdirSync(readOnly, 0o555);
const outputPath = path.join(readOnly, 'order.bpmn');
return {
inputPath,
opts: { output: outputPath },
exit: 2,
line: `Error: could not write output to ${outputPath}: EACCES: permission denied, open '${outputPath}'`,
};
},
],
[
'build: an -o naming a directory writes inside it under the default basename',
'build',
Expand Down
76 changes: 76 additions & 0 deletions packages/extension/test/conversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import type {
import {
compileCommand,
decompileCommand,
pickBpmnAndDecompileCommand,
} from '../src/extension/conversion.js';

function notifications(): Record<'info' | 'warning' | 'error', string[]> {
Expand Down Expand Up @@ -220,6 +221,18 @@ describe('conversion commands: what the author is shown', () => {
returns: undefined,
},
],
[
'decompile with nothing selected and no editor open says so and stops',
'decompile',
undefined,
undefined,
{
warning: [
'BPMNscript: No file selected. Open a .bpmn file or select one in the Explorer.',
],
returns: undefined,
},
],
[
'compile takes an upper-case extension',
'compile',
Expand Down Expand Up @@ -269,3 +282,66 @@ describe('conversion commands: what the author is shown', () => {
expect(core).toHaveBeenCalledTimes(result === undefined ? 0 : 1);
});
});

describe('conversion commands: what reaches the disk', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(compileDslToBpmn).mockResolvedValue({
ok: true,
output: '<bpmn/>',
});
});

test('unsaved edits are converted, not the stale file on disk', async () => {
const uri = vscode.Uri.file('/tmp/example.bpmnscript');
const openDocs = vscode.workspace.textDocuments as vscode.TextDocument[];
openDocs.push({ uri, getText: () => 'EDITED' } as vscode.TextDocument);
try {
await compileCommand('0.0.1')(uri);
} finally {
openDocs.length = 0;
}
expect(compileDslToBpmn).toHaveBeenCalledWith('EDITED', '0.0.1');
expect(vscode.workspace.fs.readFile).not.toHaveBeenCalled();
});

test.each([
['declining the prompt writes nothing', undefined, undefined, 0],
[
"answering 'Overwrite' writes the output",
'Overwrite',
'/tmp/example.bpmn',
1,
],
])('an existing output: %s', async (_title, answer, returns, writes) => {
vi.mocked(vscode.workspace.fs.stat).mockResolvedValueOnce(
{} as vscode.FileStat,
);
mocks.showWarningMessage.mockResolvedValueOnce(answer);

const returned = await compileCommand('0.0.1')(
vscode.Uri.file('/tmp/example.bpmnscript'),
);

expect(returned?.fsPath).toBe(returns);
expect(vscode.workspace.fs.writeFile).toHaveBeenCalledTimes(writes);
});

test.each([
['a cancelled dialog decompiles nothing', undefined, []],
[
'the picked file is decompiled',
[vscode.Uri.file('/tmp/example.bpmn')],
['/tmp/example.bpmn'],
],
])('open and decompile: %s', async (_title, picked, decompiled) => {
vi.mocked(vscode.window.showOpenDialog).mockResolvedValueOnce(picked);
const decompile = vi.fn();

await pickBpmnAndDecompileCommand(decompile)();

expect(
decompile.mock.calls.map((call) => (call[0] as vscode.Uri).fsPath),
).toEqual(decompiled);
});
});
9 changes: 5 additions & 4 deletions packages/language/src/bpmn-script-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3745,10 +3745,11 @@ export class BpmnScriptValidator {
const payload = payloadItemOf(handler.items);
if (rule.code === 'required') {
if (!code) {
accept('error', nameRequiredMessage('A message handler', 'message'), {
node: handler,
property: 'trigger',
});
accept(
'error',
nameRequiredMessage(`A ${handler.trigger} handler`, handler.trigger),
{ node: handler, property: 'trigger' },
);
}
} else if (rule.code === 'optional') {
checkEmptyCode(code, handler.items, accept);
Expand Down
10 changes: 5 additions & 5 deletions packages/language/test/validating.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,8 +431,8 @@ const HANDLER_PLACEMENT =
const HANDLER_TRAILING =
'Event handlers read like catch blocks: move it after the last step of ' +
'this body.';
const MESSAGELESS_NAME =
"A message handler needs the message's name: the engine matches messages by name.";
const nameless = (trigger: string) =>
`A ${trigger} handler needs the ${trigger}'s name: the engine matches ${trigger}s by name.`;
const CONDITION_REQUIRED =
"A condition handler needs its condition: 'on condition(amount > 100)'.";
const CONDITION_NO_CODE =
Expand Down Expand Up @@ -2517,12 +2517,12 @@ checks('Validation - event handlers', [
[
'a name-less message handler asks for the name',
`process p { on message { user A } }`,
[noFlowSteps('p'), MESSAGELESS_NAME],
[noFlowSteps('p'), nameless('message')],
],
[
'a name-less signal handler asks for the name',
`process p { on signal { user A } }`,
[noFlowSteps('p'), MESSAGELESS_NAME],
[noFlowSteps('p'), nameless('signal')],
],
[
'a payload-less timer handler asks how to read the time',
Expand All @@ -2547,7 +2547,7 @@ checks('Validation - event handlers', [
[
'a timer key on a signal handler belongs to a timer',
`process p { on signal(at: "PT1H") { user A } }`,
[noFlowSteps('p'), MESSAGELESS_NAME, PARTICLE_ONLY],
[noFlowSteps('p'), nameless('signal'), PARTICLE_ONLY],
],
[
'a code string on a condition handler is not the condition',
Expand Down
4 changes: 3 additions & 1 deletion packages/transform/src/ast-to-ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
* static position in the block tree, never a traversal counter), so lowering
* `irToDsl` output again yields identical ids. A sub-process body is rooted at
* its coordinate, not its name: gateway ids skip `resolveCollision`, so a
* sub-process named like a coordinate could otherwise duplicate one.
* sub-process named like a coordinate could otherwise duplicate one. The
* validator's reserved `Gateway_..._split` pattern relies on that too: it is
* end-anchored, so a `_2` collision suffix would slip past it.
*
* Throws only on a program the validator rejects.
*/
Expand Down
Loading