@loopstack/testing-examples 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/testing-examples.module.d.ts.map +1 -1
- package/dist/testing-examples.module.js +6 -2
- package/dist/testing-examples.module.js.map +1 -1
- package/dist/triage/approval-prompt.document.d.ts +16 -0
- package/dist/triage/approval-prompt.document.d.ts.map +1 -0
- package/dist/triage/approval-prompt.document.js +31 -0
- package/dist/triage/approval-prompt.document.js.map +1 -0
- package/dist/triage/classify-ticket.tool.d.ts +22 -0
- package/dist/triage/classify-ticket.tool.d.ts.map +1 -0
- package/dist/triage/classify-ticket.tool.js +42 -0
- package/dist/triage/classify-ticket.tool.js.map +1 -0
- package/dist/triage/index.d.ts +4 -0
- package/dist/triage/index.d.ts.map +1 -0
- package/dist/triage/index.js +20 -0
- package/dist/triage/index.js.map +1 -0
- package/dist/triage/triage-ticket.workflow.d.ts +23 -0
- package/dist/triage/triage-ticket.workflow.d.ts.map +1 -0
- package/dist/triage/triage-ticket.workflow.js +70 -0
- package/dist/triage/triage-ticket.workflow.js.map +1 -0
- package/package.json +5 -3
- package/src/index.ts +1 -0
- package/src/testing-examples.module.ts +6 -2
- package/src/triage/__tests__/__recordings__/triage.json +17 -0
- package/src/triage/__tests__/classify-ticket.tool.spec.ts +36 -0
- package/src/triage/__tests__/triage-ticket-contract-fake.spec.ts +38 -0
- package/src/triage/__tests__/triage-ticket-replay.spec.ts +62 -0
- package/src/triage/__tests__/triage-ticket.workflow.spec.ts +80 -0
- package/src/triage/approval-prompt.document.ts +28 -0
- package/src/triage/classify-ticket.tool.ts +40 -0
- package/src/triage/index.ts +3 -0
- package/src/triage/triage-ticket.workflow.ts +58 -0
package/README.md
CHANGED
|
@@ -1,19 +1,42 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: Testing Examples
|
|
3
|
-
description:
|
|
2
|
+
title: Testing Examples
|
|
3
|
+
description: Runnable workflow-testing examples for Loopstack — runWorkflow() in-process workflow tests with scripted HITL answers, parkView() park assertions, coverage() gates, testTool() tool unit tests, fixture auto record/replay, and createContractFake() DI fakes. Companion package to the Testing guide.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# @loopstack/testing-examples
|
|
7
7
|
|
|
8
|
-
>
|
|
8
|
+
> Runnable testing examples for the [Loopstack](https://loopstack.ai) automation framework — the patterns from the [Testing guide](https://loopstack.ai/docs/build/testing) and [Testing Methodology](https://loopstack.ai/docs/build/testing-methodology) as working code.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
The example under test is a small deterministic triage flow (`src/triage/`): a `classify_ticket` tool grades a support ticket, the workflow presents the severity as a yes/no approval prompt, then waits for the human decision. No LLM — the whole flow is deterministic, so every test is exact.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
- Asserting on transitions and final state
|
|
14
|
-
- Mocking LLM responses for deterministic tests
|
|
12
|
+
Scenarios are named for the acceptance criterion they check (`it('C1: …')`), so the test report reads as a criteria matrix — the discipline from the methodology guide.
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+
## What each spec demonstrates
|
|
15
|
+
|
|
16
|
+
| Spec | Pattern |
|
|
17
|
+
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
18
|
+
| `classify-ticket.tool.spec.ts` | **Tool unit test** — `testTool().forTool(...)`, assert on the envelope data (the `resultSchema` is validated by `call()`) |
|
|
19
|
+
| `triage-ticket.workflow.spec.ts` | **In-process workflow test** — `runWorkflow()` with scripted `answers`; outcome assertions on `status`/`path`/`result`/`document()`; `parkView()` for the human touchpoint; a `coverage()` gate closing the suite |
|
|
20
|
+
| `triage-ticket-replay.spec.ts` | **Record/replay** — the `fixture:` option (auto record/replay, CI-guarded) as the default, plus an inline `replay()` fixture for fully-scripted determinism |
|
|
21
|
+
| `triage-ticket-contract-fake.spec.ts` | **Contract fake** — `createContractFake()` substitutes the tool with a `resultSchema`-validated response and asserts how the workflow called it |
|
|
22
|
+
|
|
23
|
+
Run them:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm test
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Recording fixtures from live runs
|
|
30
|
+
|
|
31
|
+
The `fixture:` option records a fresh fixture when the file is missing and replays it when present. Record one from a real run:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm run start:dev # start the backend
|
|
35
|
+
loopstack run triage_ticket --arg text="Production is down!" --trace
|
|
36
|
+
loopstack runs <run-id> --record src/triage/__tests__/__recordings__/triage.json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Replay is transition-scoped: recordings are matched by the transition they were captured in, then consumed in sequence. Each entry's metadata (`tool`, `workflow`, `transition`, `args`, and `config` when present) is **asserted** against the actual call — when the outgoing args or config drift from the recording the run **fails** with a drift error naming the tool and both values, the signal to re-validate live and re-record. In CI a missing fixture is an error (not a silent live run) unless `LOOPSTACK_RECORD=1` is set.
|
|
17
40
|
|
|
18
41
|
## About
|
|
19
42
|
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,UAAU,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -15,4 +15,5 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./testing-examples.module"), exports);
|
|
18
|
+
__exportStar(require("./triage"), exports);
|
|
18
19
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,4DAA0C"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,4DAA0C;AAC1C,2CAAyB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing-examples.module.d.ts","sourceRoot":"","sources":["../src/testing-examples.module.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"testing-examples.module.d.ts","sourceRoot":"","sources":["../src/testing-examples.module.ts"],"names":[],"mappings":"AAKA,qBAOa,qBAAqB;CAAG"}
|
|
@@ -9,14 +9,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
9
9
|
exports.TestingExamplesModule = void 0;
|
|
10
10
|
const common_1 = require("@nestjs/common");
|
|
11
11
|
const common_2 = require("@loopstack/common");
|
|
12
|
+
const classify_ticket_tool_1 = require("./triage/classify-ticket.tool");
|
|
13
|
+
const triage_ticket_workflow_1 = require("./triage/triage-ticket.workflow");
|
|
12
14
|
let TestingExamplesModule = class TestingExamplesModule {
|
|
13
15
|
};
|
|
14
16
|
exports.TestingExamplesModule = TestingExamplesModule;
|
|
15
17
|
exports.TestingExamplesModule = TestingExamplesModule = __decorate([
|
|
16
18
|
(0, common_2.StudioApp)({
|
|
17
19
|
title: 'Testing Examples',
|
|
18
|
-
workflows: [],
|
|
20
|
+
workflows: [triage_ticket_workflow_1.TriageTicketWorkflow],
|
|
19
21
|
}),
|
|
20
|
-
(0, common_1.Module)({
|
|
22
|
+
(0, common_1.Module)({
|
|
23
|
+
providers: [triage_ticket_workflow_1.TriageTicketWorkflow, classify_ticket_tool_1.ClassifyTicketTool],
|
|
24
|
+
})
|
|
21
25
|
], TestingExamplesModule);
|
|
22
26
|
//# sourceMappingURL=testing-examples.module.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing-examples.module.js","sourceRoot":"","sources":["../src/testing-examples.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,8CAA8C;
|
|
1
|
+
{"version":3,"file":"testing-examples.module.js","sourceRoot":"","sources":["../src/testing-examples.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,8CAA8C;AAC9C,wEAAmE;AACnE,4EAAuE;AAShE,IAAM,qBAAqB,GAA3B,MAAM,qBAAqB;CAAG,CAAA;AAAxB,sDAAqB;gCAArB,qBAAqB;IAPjC,IAAA,kBAAS,EAAC;QACT,KAAK,EAAE,kBAAkB;QACzB,SAAS,EAAE,CAAC,6CAAoB,CAAC;KAClC,CAAC;IACD,IAAA,eAAM,EAAC;QACN,SAAS,EAAE,CAAC,6CAAoB,EAAE,yCAAkB,CAAC;KACtD,CAAC;GACW,qBAAqB,CAAG"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const ApprovalPromptSchema: z.ZodObject<{
|
|
3
|
+
question: z.ZodString;
|
|
4
|
+
severity: z.ZodEnum<{
|
|
5
|
+
high: "high";
|
|
6
|
+
normal: "normal";
|
|
7
|
+
}>;
|
|
8
|
+
answer: z.ZodOptional<z.ZodString>;
|
|
9
|
+
}, z.core.$strict>;
|
|
10
|
+
export type ApprovalPromptType = z.infer<typeof ApprovalPromptSchema>;
|
|
11
|
+
export declare class ApprovalPromptDocument {
|
|
12
|
+
question: string;
|
|
13
|
+
severity: 'high' | 'normal';
|
|
14
|
+
answer?: string;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=approval-prompt.document.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"approval-prompt.document.d.ts","sourceRoot":"","sources":["../../src/triage/approval-prompt.document.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,eAAO,MAAM,oBAAoB;;;;;;;kBAMtB,CAAC;AAEZ,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAQtE,qBAIa,sBAAsB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.ApprovalPromptDocument = exports.ApprovalPromptSchema = void 0;
|
|
10
|
+
const zod_1 = require("zod");
|
|
11
|
+
const common_1 = require("@loopstack/common");
|
|
12
|
+
exports.ApprovalPromptSchema = zod_1.z
|
|
13
|
+
.object({
|
|
14
|
+
question: zod_1.z.string(),
|
|
15
|
+
severity: zod_1.z.enum(['high', 'normal']),
|
|
16
|
+
answer: zod_1.z.string().optional(),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
let ApprovalPromptDocument = class ApprovalPromptDocument {
|
|
20
|
+
question;
|
|
21
|
+
severity;
|
|
22
|
+
answer;
|
|
23
|
+
};
|
|
24
|
+
exports.ApprovalPromptDocument = ApprovalPromptDocument;
|
|
25
|
+
exports.ApprovalPromptDocument = ApprovalPromptDocument = __decorate([
|
|
26
|
+
(0, common_1.Document)({
|
|
27
|
+
schema: exports.ApprovalPromptSchema,
|
|
28
|
+
widget: { widget: 'confirm-prompt', options: { transition: 'approve' } },
|
|
29
|
+
})
|
|
30
|
+
], ApprovalPromptDocument);
|
|
31
|
+
//# sourceMappingURL=approval-prompt.document.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"approval-prompt.document.js","sourceRoot":"","sources":["../../src/triage/approval-prompt.document.ts"],"names":[],"mappings":";;;;;;;;;AAAA,6BAAwB;AACxB,8CAA6C;AAEhC,QAAA,oBAAoB,GAAG,OAAC;KAClC,MAAM,CAAC;IACN,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE;IACpB,QAAQ,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IACpC,MAAM,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CAC9B,CAAC;KACD,MAAM,EAAE,CAAC;AAcL,IAAM,sBAAsB,GAA5B,MAAM,sBAAsB;IACjC,QAAQ,CAAS;IACjB,QAAQ,CAAoB;IAC5B,MAAM,CAAU;CACjB,CAAA;AAJY,wDAAsB;iCAAtB,sBAAsB;IAJlC,IAAA,iBAAQ,EAAC;QACR,MAAM,EAAE,4BAAoB;QAC5B,MAAM,EAAE,EAAE,MAAM,EAAE,gBAAgB,EAAE,OAAO,EAAE,EAAE,UAAU,EAAE,SAAS,EAAE,EAAE;KACzE,CAAC;GACW,sBAAsB,CAIlC"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { BaseTool, ToolEnvelope } from '@loopstack/common';
|
|
3
|
+
declare const ClassifyTicketSchema: z.ZodObject<{
|
|
4
|
+
text: z.ZodString;
|
|
5
|
+
}, z.core.$strict>;
|
|
6
|
+
type ClassifyTicketArgs = z.infer<typeof ClassifyTicketSchema>;
|
|
7
|
+
export interface ClassificationResult {
|
|
8
|
+
severity: 'high' | 'normal';
|
|
9
|
+
reason: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const ClassificationResultSchema: z.ZodObject<{
|
|
12
|
+
severity: z.ZodEnum<{
|
|
13
|
+
high: "high";
|
|
14
|
+
normal: "normal";
|
|
15
|
+
}>;
|
|
16
|
+
reason: z.ZodString;
|
|
17
|
+
}, z.core.$strict>;
|
|
18
|
+
export declare class ClassifyTicketTool extends BaseTool<ClassifyTicketArgs, object, ClassificationResult> {
|
|
19
|
+
protected handle(args: ClassifyTicketArgs): Promise<ToolEnvelope<ClassificationResult>>;
|
|
20
|
+
}
|
|
21
|
+
export {};
|
|
22
|
+
//# sourceMappingURL=classify-ticket.tool.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"classify-ticket.tool.d.ts","sourceRoot":"","sources":["../../src/triage/classify-ticket.tool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,QAAQ,EAAQ,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEjE,QAAA,MAAM,oBAAoB;;kBAIf,CAAC;AAEZ,KAAK,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAE/D,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC5B,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,eAAO,MAAM,0BAA0B;;;;;;kBAGrC,CAAC;AAIH,qBAOa,kBAAmB,SAAQ,QAAQ,CAAC,kBAAkB,EAAE,MAAM,EAAE,oBAAoB,CAAC;cAChF,MAAM,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,oBAAoB,CAAC,CAAC;CAQ9F"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.ClassifyTicketTool = exports.ClassificationResultSchema = void 0;
|
|
10
|
+
const zod_1 = require("zod");
|
|
11
|
+
const common_1 = require("@loopstack/common");
|
|
12
|
+
const ClassifyTicketSchema = zod_1.z
|
|
13
|
+
.object({
|
|
14
|
+
text: zod_1.z.string(),
|
|
15
|
+
})
|
|
16
|
+
.strict();
|
|
17
|
+
exports.ClassificationResultSchema = zod_1.z.strictObject({
|
|
18
|
+
severity: zod_1.z.enum(['high', 'normal']),
|
|
19
|
+
reason: zod_1.z.string(),
|
|
20
|
+
});
|
|
21
|
+
const URGENCY_KEYWORDS = ['down', 'outage', 'urgent', 'critical'];
|
|
22
|
+
let ClassifyTicketTool = class ClassifyTicketTool extends common_1.BaseTool {
|
|
23
|
+
async handle(args) {
|
|
24
|
+
const matched = URGENCY_KEYWORDS.find((word) => args.text.toLowerCase().includes(word));
|
|
25
|
+
return Promise.resolve({
|
|
26
|
+
data: matched
|
|
27
|
+
? { severity: 'high', reason: `matched urgency keyword "${matched}"` }
|
|
28
|
+
: { severity: 'normal', reason: 'no urgency keywords' },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
exports.ClassifyTicketTool = ClassifyTicketTool;
|
|
33
|
+
exports.ClassifyTicketTool = ClassifyTicketTool = __decorate([
|
|
34
|
+
(0, common_1.Tool)({
|
|
35
|
+
name: 'classify_ticket',
|
|
36
|
+
description: 'Classifies a support ticket as high or normal severity based on urgency keywords.',
|
|
37
|
+
schema: ClassifyTicketSchema,
|
|
38
|
+
resultSchema: exports.ClassificationResultSchema,
|
|
39
|
+
effects: 'none',
|
|
40
|
+
})
|
|
41
|
+
], ClassifyTicketTool);
|
|
42
|
+
//# sourceMappingURL=classify-ticket.tool.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"classify-ticket.tool.js","sourceRoot":"","sources":["../../src/triage/classify-ticket.tool.ts"],"names":[],"mappings":";;;;;;;;;AAAA,6BAAwB;AACxB,8CAAiE;AAEjE,MAAM,oBAAoB,GAAG,OAAC;KAC3B,MAAM,CAAC;IACN,IAAI,EAAE,OAAC,CAAC,MAAM,EAAE;CACjB,CAAC;KACD,MAAM,EAAE,CAAC;AASC,QAAA,0BAA0B,GAAG,OAAC,CAAC,YAAY,CAAC;IACvD,QAAQ,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IACpC,MAAM,EAAE,OAAC,CAAC,MAAM,EAAE;CACnB,CAAC,CAAC;AAEH,MAAM,gBAAgB,GAAG,CAAC,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,UAAU,CAAC,CAAC;AAS3D,IAAM,kBAAkB,GAAxB,MAAM,kBAAmB,SAAQ,iBAA0D;IACtF,KAAK,CAAC,MAAM,CAAC,IAAwB;QAC7C,MAAM,OAAO,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;QACxF,OAAO,OAAO,CAAC,OAAO,CAAC;YACrB,IAAI,EAAE,OAAO;gBACX,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,4BAA4B,OAAO,GAAG,EAAE;gBACtE,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,qBAAqB,EAAE;SAC1D,CAAC,CAAC;IACL,CAAC;CACF,CAAA;AATY,gDAAkB;6BAAlB,kBAAkB;IAP9B,IAAA,aAAI,EAAC;QACJ,IAAI,EAAE,iBAAiB;QACvB,WAAW,EAAE,mFAAmF;QAChG,MAAM,EAAE,oBAAoB;QAC5B,YAAY,EAAE,kCAA0B;QACxC,OAAO,EAAE,MAAM;KAChB,CAAC;GACW,kBAAkB,CAS9B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/triage/index.ts"],"names":[],"mappings":"AAAA,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wBAAwB,CAAC;AACvC,cAAc,0BAA0B,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./approval-prompt.document"), exports);
|
|
18
|
+
__exportStar(require("./classify-ticket.tool"), exports);
|
|
19
|
+
__exportStar(require("./triage-ticket.workflow"), exports);
|
|
20
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/triage/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,6DAA2C;AAC3C,yDAAuC;AACvC,2DAAyC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { BaseWorkflow } from '@loopstack/common';
|
|
3
|
+
import type { RunContext, TransitionInput } from '@loopstack/common';
|
|
4
|
+
import { ClassifyTicketTool } from './classify-ticket.tool';
|
|
5
|
+
declare const TriageTicketSchema: z.ZodObject<{
|
|
6
|
+
text: z.ZodString;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
type TriageTicketArgs = z.infer<typeof TriageTicketSchema>;
|
|
9
|
+
interface TriageTicketState {
|
|
10
|
+
severity?: 'high' | 'normal';
|
|
11
|
+
reason?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class TriageTicketWorkflow extends BaseWorkflow<TriageTicketArgs> {
|
|
14
|
+
private readonly classifyTicket;
|
|
15
|
+
constructor(classifyTicket: ClassifyTicketTool);
|
|
16
|
+
classify(state: TriageTicketState, ctx: RunContext<TriageTicketArgs>): Promise<void>;
|
|
17
|
+
report(state: TriageTicketState): Promise<void>;
|
|
18
|
+
approve(state: TriageTicketState, input: TransitionInput<{
|
|
19
|
+
answer: 'yes' | 'no';
|
|
20
|
+
}>): void;
|
|
21
|
+
}
|
|
22
|
+
export {};
|
|
23
|
+
//# sourceMappingURL=triage-ticket.workflow.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"triage-ticket.workflow.d.ts","sourceRoot":"","sources":["../../src/triage/triage-ticket.workflow.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,YAAY,EAAwB,MAAM,mBAAmB,CAAC;AACvE,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAErE,OAAO,EAAwB,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAElF,QAAA,MAAM,kBAAkB;;iBAEtB,CAAC;AAEH,KAAK,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE3D,UAAU,iBAAiB;IACzB,QAAQ,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAID,qBAOa,oBAAqB,SAAQ,YAAY,CAAC,gBAAgB,CAAC;IAC1D,OAAO,CAAC,QAAQ,CAAC,cAAc;gBAAd,cAAc,EAAE,kBAAkB;IAKzD,QAAQ,CAAC,KAAK,EAAE,iBAAiB,EAAE,GAAG,EAAE,UAAU,CAAC,gBAAgB,CAAC;IAOpE,MAAM,CAAC,KAAK,EAAE,iBAAiB;IAYrC,OAAO,CAAC,KAAK,EAAE,iBAAiB,EAAE,KAAK,EAAE,eAAe,CAAC;QAAE,MAAM,EAAE,KAAK,GAAG,IAAI,CAAA;KAAE,CAAC;CAMnF"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.TriageTicketWorkflow = void 0;
|
|
13
|
+
const zod_1 = require("zod");
|
|
14
|
+
const common_1 = require("@loopstack/common");
|
|
15
|
+
const approval_prompt_document_1 = require("./approval-prompt.document");
|
|
16
|
+
const classify_ticket_tool_1 = require("./classify-ticket.tool");
|
|
17
|
+
const TriageTicketSchema = zod_1.z.object({
|
|
18
|
+
text: zod_1.z.string(),
|
|
19
|
+
});
|
|
20
|
+
const ApprovalSchema = zod_1.z.object({ answer: zod_1.z.enum(['yes', 'no']) });
|
|
21
|
+
let TriageTicketWorkflow = class TriageTicketWorkflow extends common_1.BaseWorkflow {
|
|
22
|
+
classifyTicket;
|
|
23
|
+
constructor(classifyTicket) {
|
|
24
|
+
super();
|
|
25
|
+
this.classifyTicket = classifyTicket;
|
|
26
|
+
}
|
|
27
|
+
async classify(state, ctx) {
|
|
28
|
+
const result = await this.classifyTicket.call({ text: ctx.args.text });
|
|
29
|
+
const { severity, reason } = result.data;
|
|
30
|
+
this.assignState({ severity, reason });
|
|
31
|
+
}
|
|
32
|
+
async report(state) {
|
|
33
|
+
await this.documentStore.save(approval_prompt_document_1.ApprovalPromptDocument, {
|
|
34
|
+
severity: state.severity,
|
|
35
|
+
question: `Ticket severity: ${state.severity} (${state.reason}). Approve this classification?`,
|
|
36
|
+
}, { key: 'approval_prompt' });
|
|
37
|
+
}
|
|
38
|
+
approve(state, input) {
|
|
39
|
+
this.setResult({ severity: state.severity, approved: input.data.answer === 'yes' });
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
exports.TriageTicketWorkflow = TriageTicketWorkflow;
|
|
43
|
+
__decorate([
|
|
44
|
+
(0, common_1.Transition)({ to: 'classified' }),
|
|
45
|
+
__metadata("design:type", Function),
|
|
46
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
47
|
+
__metadata("design:returntype", Promise)
|
|
48
|
+
], TriageTicketWorkflow.prototype, "classify", null);
|
|
49
|
+
__decorate([
|
|
50
|
+
(0, common_1.Transition)({ from: 'classified', to: 'awaiting_approval' }),
|
|
51
|
+
__metadata("design:type", Function),
|
|
52
|
+
__metadata("design:paramtypes", [Object]),
|
|
53
|
+
__metadata("design:returntype", Promise)
|
|
54
|
+
], TriageTicketWorkflow.prototype, "report", null);
|
|
55
|
+
__decorate([
|
|
56
|
+
(0, common_1.Transition)({ from: 'awaiting_approval', to: 'end', wait: true, schema: ApprovalSchema }),
|
|
57
|
+
__metadata("design:type", Function),
|
|
58
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
59
|
+
__metadata("design:returntype", void 0)
|
|
60
|
+
], TriageTicketWorkflow.prototype, "approve", null);
|
|
61
|
+
exports.TriageTicketWorkflow = TriageTicketWorkflow = __decorate([
|
|
62
|
+
(0, common_1.Workflow)({
|
|
63
|
+
name: 'triage_ticket',
|
|
64
|
+
title: 'Testing - Ticket Triage Example',
|
|
65
|
+
description: 'Classifies a support ticket with a tool, presents the severity as a yes/no approval prompt, and waits for the human decision — the example workflow behind the testing guide.',
|
|
66
|
+
schema: TriageTicketSchema,
|
|
67
|
+
}),
|
|
68
|
+
__metadata("design:paramtypes", [classify_ticket_tool_1.ClassifyTicketTool])
|
|
69
|
+
], TriageTicketWorkflow);
|
|
70
|
+
//# sourceMappingURL=triage-ticket.workflow.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"triage-ticket.workflow.js","sourceRoot":"","sources":["../../src/triage/triage-ticket.workflow.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,6BAAwB;AACxB,8CAAuE;AAEvE,yEAAoE;AACpE,iEAAkF;AAElF,MAAM,kBAAkB,GAAG,OAAC,CAAC,MAAM,CAAC;IAClC,IAAI,EAAE,OAAC,CAAC,MAAM,EAAE;CACjB,CAAC,CAAC;AASH,MAAM,cAAc,GAAG,OAAC,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC;AAS5D,IAAM,oBAAoB,GAA1B,MAAM,oBAAqB,SAAQ,qBAA8B;IACzC;IAA7B,YAA6B,cAAkC;QAC7D,KAAK,EAAE,CAAC;QADmB,mBAAc,GAAd,cAAc,CAAoB;IAE/D,CAAC;IAGK,AAAN,KAAK,CAAC,QAAQ,CAAC,KAAwB,EAAE,GAAiC;QACxE,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;QACvE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC,IAA4B,CAAC;QACjE,IAAI,CAAC,WAAW,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;IACzC,CAAC;IAGK,AAAN,KAAK,CAAC,MAAM,CAAC,KAAwB;QACnC,MAAM,IAAI,CAAC,aAAa,CAAC,IAAI,CAC3B,iDAAsB,EACtB;YACE,QAAQ,EAAE,KAAK,CAAC,QAAS;YACzB,QAAQ,EAAE,oBAAoB,KAAK,CAAC,QAAQ,KAAK,KAAK,CAAC,MAAM,iCAAiC;SAC/F,EACD,EAAE,GAAG,EAAE,iBAAiB,EAAE,CAC3B,CAAC;IACJ,CAAC;IAGD,OAAO,CAAC,KAAwB,EAAE,KAAgD;QAChF,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,KAAK,EAG/E,CAAC,CAAC;IACL,CAAC;CACF,CAAA;AA/BY,oDAAoB;AAMzB;IADL,IAAA,mBAAU,EAAC,EAAE,EAAE,EAAE,YAAY,EAAE,CAAC;;;;oDAKhC;AAGK;IADL,IAAA,mBAAU,EAAC,EAAE,IAAI,EAAE,YAAY,EAAE,EAAE,EAAE,mBAAmB,EAAE,CAAC;;;;kDAU3D;AAGD;IADC,IAAA,mBAAU,EAAC,EAAE,IAAI,EAAE,mBAAmB,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC;;;;mDAMxF;+BA9BU,oBAAoB;IAPhC,IAAA,iBAAQ,EAAC;QACR,IAAI,EAAE,eAAe;QACrB,KAAK,EAAE,iCAAiC;QACxC,WAAW,EACT,+KAA+K;QACjL,MAAM,EAAE,kBAAkB;KAC3B,CAAC;qCAE6C,yCAAkB;GADpD,oBAAoB,CA+BhC"}
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loopstack/testing-examples",
|
|
3
3
|
"displayName": "Loopstack Testing Examples",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Workflow testing examples — runWorkflow in-process tests, scripted HITL answers, tool unit tests with testTool, and record/replay fixtures.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"loopstack",
|
|
7
7
|
"example",
|
|
8
8
|
"testing",
|
|
9
9
|
"workflow"
|
|
10
10
|
],
|
|
11
|
-
"version": "0.1
|
|
11
|
+
"version": "0.2.1",
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"author": {
|
|
14
14
|
"name": "Jakob Klippel",
|
|
@@ -30,13 +30,15 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@loopstack/common": "*",
|
|
33
|
-
"@nestjs/common": "^
|
|
33
|
+
"@nestjs/common": "^12.0.1",
|
|
34
|
+
"zod": "^4.3.6"
|
|
34
35
|
},
|
|
35
36
|
"files": [
|
|
36
37
|
"dist",
|
|
37
38
|
"src"
|
|
38
39
|
],
|
|
39
40
|
"devDependencies": {
|
|
41
|
+
"@loopstack/testing": "*",
|
|
40
42
|
"vitest": "^4.1.6",
|
|
41
43
|
"@swc/core": "^1.15.33",
|
|
42
44
|
"unplugin-swc": "^1.5.9"
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { Module } from '@nestjs/common';
|
|
2
2
|
import { StudioApp } from '@loopstack/common';
|
|
3
|
+
import { ClassifyTicketTool } from './triage/classify-ticket.tool';
|
|
4
|
+
import { TriageTicketWorkflow } from './triage/triage-ticket.workflow';
|
|
3
5
|
|
|
4
6
|
@StudioApp({
|
|
5
7
|
title: 'Testing Examples',
|
|
6
|
-
workflows: [],
|
|
8
|
+
workflows: [TriageTicketWorkflow],
|
|
9
|
+
})
|
|
10
|
+
@Module({
|
|
11
|
+
providers: [TriageTicketWorkflow, ClassifyTicketTool],
|
|
7
12
|
})
|
|
8
|
-
@Module({})
|
|
9
13
|
export class TestingExamplesModule {}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"recordings": [
|
|
4
|
+
{
|
|
5
|
+
"tool": "classify_ticket",
|
|
6
|
+
"workflow": "triage_ticket",
|
|
7
|
+
"transition": "classify",
|
|
8
|
+
"args": { "text": "Production is down!" },
|
|
9
|
+
"envelope": {
|
|
10
|
+
"data": {
|
|
11
|
+
"severity": "high",
|
|
12
|
+
"reason": "replayed from recording"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TestingModule } from '@nestjs/testing';
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { testTool } from '@loopstack/testing';
|
|
4
|
+
import { ClassifyTicketTool } from '../classify-ticket.tool';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tool unit test: the smallest testable unit — given args, assert on the returned envelope
|
|
8
|
+
* data. `call()` runs the tool through the real pipeline, so the declared `resultSchema` is
|
|
9
|
+
* validated on the way out.
|
|
10
|
+
*
|
|
11
|
+
* Acceptance criteria:
|
|
12
|
+
* C1 — urgency keywords grade a ticket as high severity, naming the matched keyword.
|
|
13
|
+
* C2 — a routine ticket grades as normal severity.
|
|
14
|
+
*/
|
|
15
|
+
describe('ClassifyTicketTool', () => {
|
|
16
|
+
let module: TestingModule;
|
|
17
|
+
afterEach(() => module?.close());
|
|
18
|
+
|
|
19
|
+
it('C1: grades an outage as high severity', async () => {
|
|
20
|
+
module = await testTool().forTool(ClassifyTicketTool).compile();
|
|
21
|
+
const tool = module.get(ClassifyTicketTool);
|
|
22
|
+
|
|
23
|
+
const result = await tool.call({ text: 'Production is down!' });
|
|
24
|
+
|
|
25
|
+
expect(result.data).toMatchObject({ severity: 'high', reason: 'matched urgency keyword "down"' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('C2: grades a routine question as normal severity', async () => {
|
|
29
|
+
module = await testTool().forTool(ClassifyTicketTool).compile();
|
|
30
|
+
const tool = module.get(ClassifyTicketTool);
|
|
31
|
+
|
|
32
|
+
const result = await tool.call({ text: 'How do I change my invoice address?' });
|
|
33
|
+
|
|
34
|
+
expect(result.data).toMatchObject({ severity: 'normal', reason: 'no urgency keywords' });
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createContractFake, runWorkflow } from '@loopstack/testing';
|
|
3
|
+
import { ClassifyTicketTool } from '../classify-ticket.tool';
|
|
4
|
+
import { TriageTicketWorkflow } from '../triage-ticket.workflow';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Contract fake: a DI-level tool replacement whose scripted response is validated against
|
|
8
|
+
* the tool's declared `resultSchema` at scripting time. Use it when you want to control a
|
|
9
|
+
* tool's output *and* assert how the workflow called it (`fake.call` is a vitest mock) —
|
|
10
|
+
* the contract-honest alternative to a free-form mock and the lighter-weight cousin of a
|
|
11
|
+
* recorded fixture. A scripted envelope that drifts from the tool's real result shape fails
|
|
12
|
+
* the test instead of passing against a stale mock.
|
|
13
|
+
*/
|
|
14
|
+
describe('TriageTicketWorkflow — contract fake', () => {
|
|
15
|
+
it('drives the workflow with a scripted classification and asserts the call', async () => {
|
|
16
|
+
const classify = createContractFake(ClassifyTicketTool);
|
|
17
|
+
classify.returns({ data: { severity: 'high', reason: 'scripted by the contract fake' } });
|
|
18
|
+
|
|
19
|
+
const run = await runWorkflow(
|
|
20
|
+
TriageTicketWorkflow,
|
|
21
|
+
{ text: 'Production is down!' },
|
|
22
|
+
{
|
|
23
|
+
// Substitute the tool with the fake. (For a tool that comes from an imported module,
|
|
24
|
+
// reach it with `overrides: [[Tool, fake]]` instead.)
|
|
25
|
+
providers: [{ provide: ClassifyTicketTool, useValue: classify }],
|
|
26
|
+
answers: { approve: { answer: 'yes' } },
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(run.status).toBe('completed');
|
|
31
|
+
expect(run.result).toMatchObject({ severity: 'high', approved: true });
|
|
32
|
+
// The workflow passed the ticket text straight through to the tool.
|
|
33
|
+
expect(classify.call).toHaveBeenCalledWith({ text: 'Production is down!' });
|
|
34
|
+
expect(run.document('approval_prompt')).toMatchObject({
|
|
35
|
+
question: expect.stringContaining('scripted by the contract fake'),
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { replay, runWorkflow } from '@loopstack/testing';
|
|
4
|
+
import { ClassifyTicketTool } from '../classify-ticket.tool';
|
|
5
|
+
import { TriageTicketWorkflow } from '../triage-ticket.workflow';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Record/replay: recorded tool responses are returned instead of executing the tool — the
|
|
9
|
+
* workflow's own code still runs for real. Replaying makes non-deterministic tools (above
|
|
10
|
+
* all LLM calls) deterministic; here the classifier is deterministic already, so the point
|
|
11
|
+
* is the mechanism itself.
|
|
12
|
+
*
|
|
13
|
+
* The `fixture:` option is the default path — it replays when the file exists and records a
|
|
14
|
+
* fresh fixture when it is missing (in CI a missing fixture is an error, so a forgotten
|
|
15
|
+
* `git add` can never silently downgrade the test into a live run). Record a fixture from a
|
|
16
|
+
* real run:
|
|
17
|
+
*
|
|
18
|
+
* loopstack run triage_ticket --arg text="Production is down!" --trace
|
|
19
|
+
* loopstack runs <run-id> --record src/triage/__tests__/__recordings__/triage.json
|
|
20
|
+
*/
|
|
21
|
+
describe('TriageTicketWorkflow — record/replay', () => {
|
|
22
|
+
it('replays the committed fixture instead of executing the tool', async () => {
|
|
23
|
+
const run = await runWorkflow(
|
|
24
|
+
TriageTicketWorkflow,
|
|
25
|
+
{ text: 'Production is down!' },
|
|
26
|
+
{
|
|
27
|
+
providers: [ClassifyTicketTool],
|
|
28
|
+
answers: { approve: { answer: 'yes' } },
|
|
29
|
+
fixture: join(__dirname, '__recordings__/triage.json'),
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(run.status).toBe('completed');
|
|
34
|
+
// The reason text can only come from the fixture — proof the live tool never ran.
|
|
35
|
+
expect(run.result).toMatchObject({ severity: 'high', approved: true });
|
|
36
|
+
expect(run.document('approval_prompt')).toMatchObject({
|
|
37
|
+
question: expect.stringContaining('replayed from recording'),
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('accepts an inline fixture for fully-scripted determinism', async () => {
|
|
42
|
+
const run = await runWorkflow(
|
|
43
|
+
TriageTicketWorkflow,
|
|
44
|
+
{ text: 'anything at all' },
|
|
45
|
+
{
|
|
46
|
+
providers: [ClassifyTicketTool],
|
|
47
|
+
answers: { approve: { answer: 'yes' } },
|
|
48
|
+
replay: replay({
|
|
49
|
+
version: 3,
|
|
50
|
+
recordings: [
|
|
51
|
+
{
|
|
52
|
+
tool: 'classify_ticket',
|
|
53
|
+
envelope: { data: { severity: 'normal', reason: 'inline fixture' } },
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(run.result).toMatchObject({ severity: 'normal', approved: true });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { type TestRun, coverage, runWorkflow } from '@loopstack/testing';
|
|
3
|
+
import { TestingExamplesModule } from '../../testing-examples.module';
|
|
4
|
+
import { ClassifyTicketTool } from '../classify-ticket.tool';
|
|
5
|
+
import { TriageTicketWorkflow } from '../triage-ticket.workflow';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Workflow tests: the real state machine runs in-process — no backend, no database.
|
|
9
|
+
* Each scenario is named for the acceptance criterion it checks (`C1:`…), so the test
|
|
10
|
+
* report reads as a criteria matrix (see the Testing Methodology guide). Assert on
|
|
11
|
+
* outcomes — status, path, result, documents, and what the user sees at a park — never
|
|
12
|
+
* on internal mechanics.
|
|
13
|
+
*
|
|
14
|
+
* Acceptance criteria:
|
|
15
|
+
* C1 — a high-severity ticket is classified, reported, and (on approval) completes as approved.
|
|
16
|
+
* C2 — while awaiting approval the run parks showing a yes/no prompt with the severity.
|
|
17
|
+
* C3 — a rejected classification completes as not approved.
|
|
18
|
+
*/
|
|
19
|
+
describe('TriageTicketWorkflow', () => {
|
|
20
|
+
const runs: TestRun[] = [];
|
|
21
|
+
const run = async (text: string, answer?: 'yes' | 'no') => {
|
|
22
|
+
const result = await runWorkflow(
|
|
23
|
+
TriageTicketWorkflow,
|
|
24
|
+
{ text },
|
|
25
|
+
{
|
|
26
|
+
// Import the app module so the document's widget config is discoverable —
|
|
27
|
+
// parkView() resolves the same widget the CLI and Studio render.
|
|
28
|
+
imports: [TestingExamplesModule],
|
|
29
|
+
providers: [ClassifyTicketTool],
|
|
30
|
+
...(answer ? { answers: { approve: { answer } } } : {}),
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
runs.push(result);
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
it('C1: classifies an outage as high severity and completes when approved', async () => {
|
|
38
|
+
const result = await run('Production is down!', 'yes');
|
|
39
|
+
|
|
40
|
+
expect(result.status).toBe('completed');
|
|
41
|
+
expect(result.path).toEqual(['classify', 'report', 'approve']);
|
|
42
|
+
expect(result.result).toMatchObject({ severity: 'high', approved: true });
|
|
43
|
+
expect(result.document('approval_prompt')).toMatchObject({
|
|
44
|
+
severity: 'high',
|
|
45
|
+
question: 'Ticket severity: high (matched urgency keyword "down"). Approve this classification?',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('C2: parks for approval showing the severity prompt', async () => {
|
|
50
|
+
const result = await run('Where is my invoice?');
|
|
51
|
+
|
|
52
|
+
expect(result.status).toBe('waiting');
|
|
53
|
+
expect(result.place).toBe('awaiting_approval');
|
|
54
|
+
expect(result.path).toEqual(['classify', 'report']);
|
|
55
|
+
|
|
56
|
+
// What the human would see at this park — resolved by the same rules the CLI and Studio use.
|
|
57
|
+
const view = result.parkView();
|
|
58
|
+
expect(view).toMatchObject({
|
|
59
|
+
widget: 'confirm-prompt',
|
|
60
|
+
documentName: 'approval_prompt',
|
|
61
|
+
content: { severity: 'normal', question: expect.stringContaining('normal') },
|
|
62
|
+
transitions: ['approve'],
|
|
63
|
+
defaultTransition: 'approve',
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('C3: records a rejected classification', async () => {
|
|
68
|
+
const result = await run('Urgent: cannot log in', 'no');
|
|
69
|
+
|
|
70
|
+
expect(result.status).toBe('completed');
|
|
71
|
+
expect(result.result).toMatchObject({ severity: 'high', approved: false });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('covers every declared transition and park (coverage gate)', () => {
|
|
75
|
+
const cov = coverage(runs, TriageTicketWorkflow);
|
|
76
|
+
expect(cov.missingTransitions).toEqual([]);
|
|
77
|
+
expect(cov.missingParks).toEqual([]);
|
|
78
|
+
expect(cov.complete).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { Document } from '@loopstack/common';
|
|
3
|
+
|
|
4
|
+
export const ApprovalPromptSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
question: z.string(),
|
|
7
|
+
severity: z.enum(['high', 'normal']),
|
|
8
|
+
answer: z.string().optional(),
|
|
9
|
+
})
|
|
10
|
+
.strict();
|
|
11
|
+
|
|
12
|
+
export type ApprovalPromptType = z.infer<typeof ApprovalPromptSchema>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The human touchpoint of the triage flow: a yes/no approval prompt shown while the
|
|
16
|
+
* workflow waits. The inline `confirm-prompt` widget binds the "Yes"/"No" answer to the
|
|
17
|
+
* `approve` wait transition — the same widget the CLI and Studio render, so a test can
|
|
18
|
+
* assert `parkView()` (widget, question, default transition) exactly as a user would see it.
|
|
19
|
+
*/
|
|
20
|
+
@Document({
|
|
21
|
+
schema: ApprovalPromptSchema,
|
|
22
|
+
widget: { widget: 'confirm-prompt', options: { transition: 'approve' } },
|
|
23
|
+
})
|
|
24
|
+
export class ApprovalPromptDocument {
|
|
25
|
+
question: string;
|
|
26
|
+
severity: 'high' | 'normal';
|
|
27
|
+
answer?: string;
|
|
28
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { BaseTool, Tool, ToolEnvelope } from '@loopstack/common';
|
|
3
|
+
|
|
4
|
+
const ClassifyTicketSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
text: z.string(),
|
|
7
|
+
})
|
|
8
|
+
.strict();
|
|
9
|
+
|
|
10
|
+
type ClassifyTicketArgs = z.infer<typeof ClassifyTicketSchema>;
|
|
11
|
+
|
|
12
|
+
export interface ClassificationResult {
|
|
13
|
+
severity: 'high' | 'normal';
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const ClassificationResultSchema = z.strictObject({
|
|
18
|
+
severity: z.enum(['high', 'normal']),
|
|
19
|
+
reason: z.string(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const URGENCY_KEYWORDS = ['down', 'outage', 'urgent', 'critical'];
|
|
23
|
+
|
|
24
|
+
@Tool({
|
|
25
|
+
name: 'classify_ticket',
|
|
26
|
+
description: 'Classifies a support ticket as high or normal severity based on urgency keywords.',
|
|
27
|
+
schema: ClassifyTicketSchema,
|
|
28
|
+
resultSchema: ClassificationResultSchema,
|
|
29
|
+
effects: 'none',
|
|
30
|
+
})
|
|
31
|
+
export class ClassifyTicketTool extends BaseTool<ClassifyTicketArgs, object, ClassificationResult> {
|
|
32
|
+
protected async handle(args: ClassifyTicketArgs): Promise<ToolEnvelope<ClassificationResult>> {
|
|
33
|
+
const matched = URGENCY_KEYWORDS.find((word) => args.text.toLowerCase().includes(word));
|
|
34
|
+
return Promise.resolve({
|
|
35
|
+
data: matched
|
|
36
|
+
? { severity: 'high', reason: `matched urgency keyword "${matched}"` }
|
|
37
|
+
: { severity: 'normal', reason: 'no urgency keywords' },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { BaseWorkflow, Transition, Workflow } from '@loopstack/common';
|
|
3
|
+
import type { RunContext, TransitionInput } from '@loopstack/common';
|
|
4
|
+
import { ApprovalPromptDocument } from './approval-prompt.document';
|
|
5
|
+
import { ClassificationResult, ClassifyTicketTool } from './classify-ticket.tool';
|
|
6
|
+
|
|
7
|
+
const TriageTicketSchema = z.object({
|
|
8
|
+
text: z.string(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
type TriageTicketArgs = z.infer<typeof TriageTicketSchema>;
|
|
12
|
+
|
|
13
|
+
interface TriageTicketState {
|
|
14
|
+
severity?: 'high' | 'normal';
|
|
15
|
+
reason?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ApprovalSchema = z.object({ answer: z.enum(['yes', 'no']) });
|
|
19
|
+
|
|
20
|
+
@Workflow({
|
|
21
|
+
name: 'triage_ticket',
|
|
22
|
+
title: 'Testing - Ticket Triage Example',
|
|
23
|
+
description:
|
|
24
|
+
'Classifies a support ticket with a tool, presents the severity as a yes/no approval prompt, and waits for the human decision — the example workflow behind the testing guide.',
|
|
25
|
+
schema: TriageTicketSchema,
|
|
26
|
+
})
|
|
27
|
+
export class TriageTicketWorkflow extends BaseWorkflow<TriageTicketArgs> {
|
|
28
|
+
constructor(private readonly classifyTicket: ClassifyTicketTool) {
|
|
29
|
+
super();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@Transition({ to: 'classified' })
|
|
33
|
+
async classify(state: TriageTicketState, ctx: RunContext<TriageTicketArgs>) {
|
|
34
|
+
const result = await this.classifyTicket.call({ text: ctx.args.text });
|
|
35
|
+
const { severity, reason } = result.data as ClassificationResult;
|
|
36
|
+
this.assignState({ severity, reason });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Transition({ from: 'classified', to: 'awaiting_approval' })
|
|
40
|
+
async report(state: TriageTicketState) {
|
|
41
|
+
await this.documentStore.save(
|
|
42
|
+
ApprovalPromptDocument,
|
|
43
|
+
{
|
|
44
|
+
severity: state.severity!,
|
|
45
|
+
question: `Ticket severity: ${state.severity} (${state.reason}). Approve this classification?`,
|
|
46
|
+
},
|
|
47
|
+
{ key: 'approval_prompt' },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Transition({ from: 'awaiting_approval', to: 'end', wait: true, schema: ApprovalSchema })
|
|
52
|
+
approve(state: TriageTicketState, input: TransitionInput<{ answer: 'yes' | 'no' }>) {
|
|
53
|
+
this.setResult({ severity: state.severity, approved: input.data.answer === 'yes' } as unknown as Record<
|
|
54
|
+
string,
|
|
55
|
+
unknown
|
|
56
|
+
>);
|
|
57
|
+
}
|
|
58
|
+
}
|