donobu 5.36.0 → 5.36.2
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/dist/esm/managers/DonobuFlowsManager.d.ts +21 -1
- package/dist/esm/managers/DonobuFlowsManager.js +51 -2
- package/dist/esm/managers/DonobuStack.js +4 -4
- package/dist/esm/managers/FederatedPagination.d.ts +43 -13
- package/dist/esm/managers/FederatedPagination.js +122 -39
- package/dist/esm/managers/SuitesManager.js +11 -6
- package/dist/esm/managers/TestsManager.d.ts +20 -2
- package/dist/esm/managers/TestsManager.js +67 -14
- package/dist/esm/persistence/flows/FlowsPersistenceDonobuApi.js +10 -1
- package/dist/esm/persistence/flows/FlowsPersistenceRegistry.d.ts +25 -1
- package/dist/esm/persistence/flows/FlowsPersistenceRegistry.js +17 -5
- package/dist/esm/persistence/suites/SuitesPersistenceRegistry.d.ts +18 -1
- package/dist/esm/persistence/suites/SuitesPersistenceRegistry.js +17 -5
- package/dist/esm/persistence/tests/TestsPersistenceRegistry.d.ts +18 -1
- package/dist/esm/persistence/tests/TestsPersistenceRegistry.js +20 -5
- package/dist/esm/reporter/render.js +36 -10
- package/dist/esm/tools/ScrollPageTool.js +1 -1
- package/dist/managers/DonobuFlowsManager.d.ts +21 -1
- package/dist/managers/DonobuFlowsManager.js +51 -2
- package/dist/managers/DonobuStack.js +4 -4
- package/dist/managers/FederatedPagination.d.ts +43 -13
- package/dist/managers/FederatedPagination.js +122 -39
- package/dist/managers/SuitesManager.js +11 -6
- package/dist/managers/TestsManager.d.ts +20 -2
- package/dist/managers/TestsManager.js +67 -14
- package/dist/persistence/flows/FlowsPersistenceDonobuApi.js +10 -1
- package/dist/persistence/flows/FlowsPersistenceRegistry.d.ts +25 -1
- package/dist/persistence/flows/FlowsPersistenceRegistry.js +17 -5
- package/dist/persistence/suites/SuitesPersistenceRegistry.d.ts +18 -1
- package/dist/persistence/suites/SuitesPersistenceRegistry.js +17 -5
- package/dist/persistence/tests/TestsPersistenceRegistry.d.ts +18 -1
- package/dist/persistence/tests/TestsPersistenceRegistry.js +20 -5
- package/dist/reporter/render.js +36 -10
- package/dist/tools/ScrollPageTool.js +1 -1
- package/package.json +1 -1
|
@@ -8,67 +8,150 @@ function createCompositePageToken(state) {
|
|
|
8
8
|
}
|
|
9
9
|
function parseCompositePageToken(token) {
|
|
10
10
|
if (!token) {
|
|
11
|
-
return {
|
|
11
|
+
return { sources: [] };
|
|
12
12
|
}
|
|
13
13
|
try {
|
|
14
|
-
|
|
14
|
+
const decoded = JSON.parse(Buffer.from(token, 'base64').toString());
|
|
15
|
+
if (decoded && Array.isArray(decoded.sources)) {
|
|
16
|
+
return decoded;
|
|
17
|
+
}
|
|
15
18
|
}
|
|
16
19
|
catch {
|
|
17
|
-
|
|
20
|
+
// fall through
|
|
18
21
|
}
|
|
22
|
+
return { sources: [] };
|
|
19
23
|
}
|
|
20
24
|
/**
|
|
21
|
-
* Federate a paginated listing across multiple persistence layers
|
|
25
|
+
* Federate a paginated listing across multiple persistence layers
|
|
26
|
+
* (resume-by-id).
|
|
27
|
+
*
|
|
28
|
+
* Each layer holds an opaque cursor and an optional `resumeAfterId`. On
|
|
29
|
+
* each page request, federation fetches from each non-exhausted layer
|
|
30
|
+
* (advancing through batches as needed until it has at least `limit`
|
|
31
|
+
* candidates per layer), stable-sorts the merged contributions with the
|
|
32
|
+
* caller's `comparator`, and trims to `limit`. For each layer, the
|
|
33
|
+
* **last kept item in that layer's order** becomes the new
|
|
34
|
+
* `resumeAfterId`, with `cursor` set to the layer-token used to fetch
|
|
35
|
+
* that item's batch. Layers whose contribution is wholly dropped by the
|
|
36
|
+
* trim retain their pre-call state so their items remain reachable on
|
|
37
|
+
* later pages.
|
|
22
38
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
39
|
+
* **Critical contract: the comparator must NOT tiebreak by id.** Ties
|
|
40
|
+
* on the sort key must return 0 from `comparator`. `Array.sort` is
|
|
41
|
+
* stable since ES2019, and federation feeds the merge in
|
|
42
|
+
* `[layer0..., layer1..., ...]` insertion order, so ties resolve by
|
|
43
|
+
* `(layerIndex, layer-internal position)` — exactly the property that
|
|
44
|
+
* keeps each layer's kept items as a *prefix* of its own order, which
|
|
45
|
+
* is what makes a single `resumeAfterId` representable. An id
|
|
46
|
+
* tiebreaker would silently break this and lose items.
|
|
26
47
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
48
|
+
* **Layer contract.** Layers must return the same items in the same
|
|
49
|
+
* order for the same `(query, pageToken)` pair across calls. Their
|
|
50
|
+
* internal ordering does not need to match federation's tiebreaker —
|
|
51
|
+
* just be deterministic.
|
|
31
52
|
*/
|
|
32
53
|
async function federatedList(layers, query, comparator) {
|
|
33
|
-
const paginationState = parseCompositePageToken(query.pageToken);
|
|
34
54
|
const requestedLimit = Math.min(Math.max(1, query.limit ?? 100), 100);
|
|
35
|
-
const
|
|
36
|
-
|
|
55
|
+
const incomingState = parseCompositePageToken(query.pageToken);
|
|
56
|
+
const initialFor = (i) => {
|
|
57
|
+
const s = incomingState.sources[i];
|
|
58
|
+
return s ? { ...s } : { exhausted: false };
|
|
59
|
+
};
|
|
60
|
+
const work = [];
|
|
37
61
|
for (let i = 0; i < layers.length; i++) {
|
|
38
|
-
|
|
62
|
+
const init = initialFor(i);
|
|
63
|
+
const w = { contribution: [], layerExhausted: false };
|
|
64
|
+
work.push(w);
|
|
65
|
+
if (init.exhausted) {
|
|
66
|
+
w.layerExhausted = true;
|
|
39
67
|
continue;
|
|
40
68
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
69
|
+
let cursor = init.cursor;
|
|
70
|
+
let resume = init.resumeAfterId;
|
|
71
|
+
while (w.contribution.length < requestedLimit) {
|
|
72
|
+
const layerQuery = {
|
|
73
|
+
...query,
|
|
74
|
+
limit: requestedLimit,
|
|
75
|
+
pageToken: cursor,
|
|
76
|
+
};
|
|
77
|
+
const batch = await layers[i].getItems(layerQuery);
|
|
78
|
+
let items = batch.items;
|
|
79
|
+
if (resume !== undefined) {
|
|
80
|
+
const idx = items.findIndex((it) => it.id === resume);
|
|
81
|
+
if (idx >= 0) {
|
|
82
|
+
items = items.slice(idx + 1);
|
|
83
|
+
}
|
|
84
|
+
// The skip is one-shot: clear `resume` regardless of whether we
|
|
85
|
+
// found it. If found, we've passed it; if not, it was in a
|
|
86
|
+
// previously-fetched batch and won't reappear in subsequent
|
|
87
|
+
// batches we fetch in this loop.
|
|
88
|
+
resume = undefined;
|
|
89
|
+
}
|
|
90
|
+
for (const it of items) {
|
|
91
|
+
w.contribution.push({
|
|
92
|
+
item: it,
|
|
93
|
+
layerIndex: i,
|
|
94
|
+
fetchCursor: cursor,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (!batch.nextPageToken) {
|
|
98
|
+
w.layerExhausted = true;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
cursor = batch.nextPageToken;
|
|
50
102
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
103
|
+
}
|
|
104
|
+
// Stable sort: ties resolve by insertion order = (layerIndex, layer
|
|
105
|
+
// position). Do NOT add an id tiebreaker (see header comment).
|
|
106
|
+
const merged = [];
|
|
107
|
+
for (const w of work) {
|
|
108
|
+
merged.push(...w.contribution);
|
|
109
|
+
}
|
|
110
|
+
merged.sort((a, b) => comparator(a.item, b.item));
|
|
111
|
+
const page = merged.slice(0, requestedLimit);
|
|
112
|
+
const newSources = [];
|
|
113
|
+
for (let i = 0; i < layers.length; i++) {
|
|
114
|
+
const init = initialFor(i);
|
|
115
|
+
if (init.exhausted) {
|
|
116
|
+
newSources.push({ exhausted: true });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const keptFromLayer = page.filter((w) => w.layerIndex === i);
|
|
120
|
+
if (keptFromLayer.length === 0) {
|
|
121
|
+
if (work[i].contribution.length === 0 && work[i].layerExhausted) {
|
|
122
|
+
// Layer truly has nothing — empty stream. Mark exhausted so
|
|
123
|
+
// we don't re-query it forever.
|
|
124
|
+
newSources.push({ exhausted: true });
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// Layer's items were all outranked by other layers. Roll back
|
|
128
|
+
// to the incoming state so they remain reachable on later
|
|
129
|
+
// pages, when the outranking layers have moved on.
|
|
130
|
+
newSources.push(init);
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const lastKept = keptFromLayer[keptFromLayer.length - 1];
|
|
135
|
+
const allFromLayer = work[i].contribution;
|
|
136
|
+
const allKept = allFromLayer.length === keptFromLayer.length;
|
|
137
|
+
if (work[i].layerExhausted && allKept) {
|
|
138
|
+
newSources.push({ exhausted: true });
|
|
54
139
|
}
|
|
55
140
|
else {
|
|
56
|
-
|
|
141
|
+
newSources.push({
|
|
142
|
+
cursor: lastKept.fetchCursor,
|
|
143
|
+
resumeAfterId: lastKept.item.id,
|
|
144
|
+
exhausted: false,
|
|
145
|
+
});
|
|
57
146
|
}
|
|
58
147
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (layersThatReturnedResults > 1) {
|
|
63
|
-
combinedResults.sort(comparator);
|
|
64
|
-
}
|
|
65
|
-
const limitedResults = combinedResults.slice(0, requestedLimit);
|
|
66
|
-
const hasMore = combinedResults.length > requestedLimit ||
|
|
67
|
-
paginationState.exhaustedSources.length < layers.length;
|
|
148
|
+
const someUnconsumed = merged.length > requestedLimit;
|
|
149
|
+
const someNotExhausted = newSources.some((s) => !s.exhausted);
|
|
150
|
+
const hasMore = someUnconsumed || someNotExhausted;
|
|
68
151
|
return {
|
|
69
|
-
items:
|
|
152
|
+
items: page.map((w) => w.item),
|
|
70
153
|
nextPageToken: hasMore
|
|
71
|
-
? createCompositePageToken(
|
|
154
|
+
? createCompositePageToken({ sources: newSources })
|
|
72
155
|
: undefined,
|
|
73
156
|
};
|
|
74
157
|
}
|
|
@@ -108,16 +108,21 @@ class SuitesManager {
|
|
|
108
108
|
* behavior for non-DB persistence layers (Volatile, S3, GCS).
|
|
109
109
|
*/
|
|
110
110
|
async deleteSuite(suiteId) {
|
|
111
|
-
const
|
|
112
|
-
const testLayers = await this.testsPersistenceRegistry.getAll();
|
|
113
|
-
for (let i = 0; i < suiteLayers.length; i++) {
|
|
111
|
+
for (const { key, persistence, } of await this.suitesPersistenceRegistry.getEntries()) {
|
|
114
112
|
try {
|
|
115
|
-
await
|
|
113
|
+
await persistence.deleteSuite(suiteId);
|
|
116
114
|
// Orphan tests in this layer after successfully deleting the suite.
|
|
117
115
|
// This mirrors the DB-level ON DELETE SET NULL for non-DB layers.
|
|
118
|
-
|
|
116
|
+
// Pair by key — the suites and tests registries can have different
|
|
117
|
+
// sets of layers (e.g. plugin-only suites persistence) so positional
|
|
118
|
+
// indexing isn't safe.
|
|
119
|
+
const testsPersistence = await this.testsPersistenceRegistry.getByKey(key);
|
|
120
|
+
if (!testsPersistence) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const testsResult = await testsPersistence.getTests({ suiteId });
|
|
119
124
|
for (const test of testsResult.items) {
|
|
120
|
-
await
|
|
125
|
+
await testsPersistence.updateTest({ ...test, suiteId: null });
|
|
121
126
|
}
|
|
122
127
|
}
|
|
123
128
|
catch {
|
|
@@ -2,14 +2,30 @@ import type { CreateDonobuFlow } from '../models/CreateDonobuFlow';
|
|
|
2
2
|
import type { CreateTest } from '../models/CreateTest';
|
|
3
3
|
import type { PaginatedResult } from '../models/PaginatedResult';
|
|
4
4
|
import type { TestListItem, TestMetadata, TestsQuery } from '../models/TestMetadata';
|
|
5
|
+
import type { SuitesPersistenceRegistry } from '../persistence/suites/SuitesPersistenceRegistry';
|
|
5
6
|
import type { TestsPersistenceRegistry } from '../persistence/tests/TestsPersistenceRegistry';
|
|
6
7
|
import type { DonobuFlowsManager } from './DonobuFlowsManager';
|
|
7
8
|
export declare class TestsManager {
|
|
8
9
|
private readonly testsPersistenceRegistry;
|
|
10
|
+
private readonly suitesPersistenceRegistry;
|
|
9
11
|
private readonly flowsManager;
|
|
10
|
-
constructor(testsPersistenceRegistry: TestsPersistenceRegistry, flowsManager: DonobuFlowsManager);
|
|
12
|
+
constructor(testsPersistenceRegistry: TestsPersistenceRegistry, suitesPersistenceRegistry: SuitesPersistenceRegistry, flowsManager: DonobuFlowsManager);
|
|
11
13
|
createTest(params: CreateTest): Promise<TestMetadata>;
|
|
12
14
|
getTestById(testId: string): Promise<TestMetadata>;
|
|
15
|
+
/**
|
|
16
|
+
* Picks the tests persistence layer to use when creating a new test.
|
|
17
|
+
*
|
|
18
|
+
* - If `suiteId` is null/undefined: use the primary tests layer.
|
|
19
|
+
* - If `suiteId` is set: look up the suite's layer key and use the matching
|
|
20
|
+
* tests layer. If no tests layer matches the suite's key (rare — would
|
|
21
|
+
* require asymmetric registry config), fall back to the primary tests
|
|
22
|
+
* layer; the FK won't hold but at least the test is persisted somewhere.
|
|
23
|
+
* - If `suiteId` is set but the suite doesn't exist anywhere: fall back
|
|
24
|
+
* to the primary tests layer (the SQLite FK will reject if applicable;
|
|
25
|
+
* non-DB layers will accept the dangling reference).
|
|
26
|
+
*/
|
|
27
|
+
private resolveLayerForTestCreate;
|
|
28
|
+
private findSuiteLayerKey;
|
|
13
29
|
getTests(query: TestsQuery): Promise<PaginatedResult<TestListItem>>;
|
|
14
30
|
/**
|
|
15
31
|
* Update a test in the persistence layer where it exists.
|
|
@@ -38,7 +54,9 @@ export declare class TestsManager {
|
|
|
38
54
|
private getTestToolCalls;
|
|
39
55
|
/**
|
|
40
56
|
* Creates a new flow (config) for the given test, which should be passed to
|
|
41
|
-
* `flowsManager.createFlow` to execute the test.
|
|
57
|
+
* `flowsManager.createFlow` to execute the test. The returned config's
|
|
58
|
+
* `testId` is set, which `createFlow` uses to route the flow to the same
|
|
59
|
+
* persistence layer as the test.
|
|
42
60
|
*
|
|
43
61
|
* @param testId - The ID of the test to create a new flow for
|
|
44
62
|
*
|
|
@@ -3,13 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.TestsManager = void 0;
|
|
4
4
|
const crypto_1 = require("crypto");
|
|
5
5
|
const CannotDeleteRunningFlowException_1 = require("../exceptions/CannotDeleteRunningFlowException");
|
|
6
|
+
const SuiteNotFoundException_1 = require("../exceptions/SuiteNotFoundException");
|
|
6
7
|
const TestNotFoundException_1 = require("../exceptions/TestNotFoundException");
|
|
7
8
|
const buildProvenance_1 = require("../utils/buildProvenance");
|
|
8
9
|
const displayName_1 = require("../utils/displayName");
|
|
9
10
|
const FederatedPagination_1 = require("./FederatedPagination");
|
|
10
11
|
class TestsManager {
|
|
11
|
-
constructor(testsPersistenceRegistry, flowsManager) {
|
|
12
|
+
constructor(testsPersistenceRegistry, suitesPersistenceRegistry, flowsManager) {
|
|
12
13
|
this.testsPersistenceRegistry = testsPersistenceRegistry;
|
|
14
|
+
this.suitesPersistenceRegistry = suitesPersistenceRegistry;
|
|
13
15
|
this.flowsManager = flowsManager;
|
|
14
16
|
}
|
|
15
17
|
async createTest(params) {
|
|
@@ -38,9 +40,12 @@ class TestsManager {
|
|
|
38
40
|
videoDisabled: params.videoDisabled,
|
|
39
41
|
provenance: (0, buildProvenance_1.buildProvenance)('DONOBU_STUDIO'),
|
|
40
42
|
};
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
// If the test is part of a suite, write it to the same persistence layer
|
|
44
|
+
// as the suite — otherwise the suite_id foreign key fails (in SQLite)
|
|
45
|
+
// or leaves a dangling reference (in non-DB layers). When standalone,
|
|
46
|
+
// fall back to the primary layer.
|
|
47
|
+
const persistence = await this.resolveLayerForTestCreate(testMetadata.suiteId);
|
|
48
|
+
await persistence.createTest(testMetadata);
|
|
44
49
|
return testMetadata;
|
|
45
50
|
}
|
|
46
51
|
async getTestById(testId) {
|
|
@@ -56,6 +61,49 @@ class TestsManager {
|
|
|
56
61
|
}
|
|
57
62
|
throw TestNotFoundException_1.TestNotFoundException.forId(testId);
|
|
58
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Picks the tests persistence layer to use when creating a new test.
|
|
66
|
+
*
|
|
67
|
+
* - If `suiteId` is null/undefined: use the primary tests layer.
|
|
68
|
+
* - If `suiteId` is set: look up the suite's layer key and use the matching
|
|
69
|
+
* tests layer. If no tests layer matches the suite's key (rare — would
|
|
70
|
+
* require asymmetric registry config), fall back to the primary tests
|
|
71
|
+
* layer; the FK won't hold but at least the test is persisted somewhere.
|
|
72
|
+
* - If `suiteId` is set but the suite doesn't exist anywhere: fall back
|
|
73
|
+
* to the primary tests layer (the SQLite FK will reject if applicable;
|
|
74
|
+
* non-DB layers will accept the dangling reference).
|
|
75
|
+
*/
|
|
76
|
+
async resolveLayerForTestCreate(suiteId) {
|
|
77
|
+
if (!suiteId) {
|
|
78
|
+
return this.testsPersistenceRegistry.get();
|
|
79
|
+
}
|
|
80
|
+
let suiteLayerKey;
|
|
81
|
+
try {
|
|
82
|
+
suiteLayerKey = await this.findSuiteLayerKey(suiteId);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (!(error instanceof SuiteNotFoundException_1.SuiteNotFoundException)) {
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
return this.testsPersistenceRegistry.get();
|
|
89
|
+
}
|
|
90
|
+
const matched = await this.testsPersistenceRegistry.getByKey(suiteLayerKey);
|
|
91
|
+
return matched ?? (await this.testsPersistenceRegistry.get());
|
|
92
|
+
}
|
|
93
|
+
async findSuiteLayerKey(suiteId) {
|
|
94
|
+
for (const { key, persistence, } of await this.suitesPersistenceRegistry.getEntries()) {
|
|
95
|
+
try {
|
|
96
|
+
await persistence.getSuiteById(suiteId);
|
|
97
|
+
return key;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (!(error instanceof SuiteNotFoundException_1.SuiteNotFoundException)) {
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
throw SuiteNotFoundException_1.SuiteNotFoundException.forId(suiteId);
|
|
106
|
+
}
|
|
59
107
|
async getTests(query) {
|
|
60
108
|
const layers = (await this.testsPersistenceRegistry.getAll()).map((persistence) => ({
|
|
61
109
|
getItems: (q) => persistence.getTests(q),
|
|
@@ -65,13 +113,6 @@ class TestsManager {
|
|
|
65
113
|
// TestMetadata has no `created_at` field — fall back to "now" so newly
|
|
66
114
|
// returned items sort to the most-recent end. For other sort columns,
|
|
67
115
|
// map the snake_case API name to the camelCase JS field.
|
|
68
|
-
//
|
|
69
|
-
// `flow_count` and `latest_flow_created_at` are only known at the
|
|
70
|
-
// persistence layer (SQLite computes them via JOIN; the cloud handles
|
|
71
|
-
// them server-side). Returning 0 here means each layer's pre-sorted
|
|
72
|
-
// results stay in their own order during the federated merge, but
|
|
73
|
-
// ordering across layers degrades to "first seen wins" — fine for the
|
|
74
|
-
// common single-primary-layer case.
|
|
75
116
|
const fieldFor = (test) => {
|
|
76
117
|
switch (sortBy) {
|
|
77
118
|
case 'created_at':
|
|
@@ -83,8 +124,9 @@ class TestsManager {
|
|
|
83
124
|
case 'next_run_mode':
|
|
84
125
|
return test.nextRunMode;
|
|
85
126
|
case 'flow_count':
|
|
127
|
+
return test.flowCount ?? 0;
|
|
86
128
|
case 'latest_flow_created_at':
|
|
87
|
-
return
|
|
129
|
+
return test.latestFlow?.startedAt ?? Date.now();
|
|
88
130
|
default:
|
|
89
131
|
return '';
|
|
90
132
|
}
|
|
@@ -196,7 +238,9 @@ class TestsManager {
|
|
|
196
238
|
}
|
|
197
239
|
/**
|
|
198
240
|
* Creates a new flow (config) for the given test, which should be passed to
|
|
199
|
-
* `flowsManager.createFlow` to execute the test.
|
|
241
|
+
* `flowsManager.createFlow` to execute the test. The returned config's
|
|
242
|
+
* `testId` is set, which `createFlow` uses to route the flow to the same
|
|
243
|
+
* persistence layer as the test.
|
|
200
244
|
*
|
|
201
245
|
* @param testId - The ID of the test to create a new flow for
|
|
202
246
|
*
|
|
@@ -210,7 +254,16 @@ class TestsManager {
|
|
|
210
254
|
try {
|
|
211
255
|
toolCallsOnStart = await this.getTestToolCalls(test);
|
|
212
256
|
}
|
|
213
|
-
catch {
|
|
257
|
+
catch (error) {
|
|
258
|
+
// Fallback to AUTONOMOUS is only viable when the test has an
|
|
259
|
+
// overallObjective for the agent to work toward. For tests without
|
|
260
|
+
// one (e.g. Playwright-imported tests, or AI tests where the user
|
|
261
|
+
// cleared the objective), AUTONOMOUS would just fail downstream
|
|
262
|
+
// with a misleading "overallObjective is required" — propagate the
|
|
263
|
+
// original error instead so the user sees the real cause.
|
|
264
|
+
if ((test.overallObjective?.trim().length ?? 0) === 0) {
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
214
267
|
runMode = 'AUTONOMOUS';
|
|
215
268
|
}
|
|
216
269
|
}
|
|
@@ -125,7 +125,16 @@ class FlowsPersistenceDonobuApi {
|
|
|
125
125
|
params.set('orphaned', query.orphaned ? 'true' : 'false');
|
|
126
126
|
}
|
|
127
127
|
if (query.sortBy) {
|
|
128
|
-
|
|
128
|
+
switch (query.sortBy) {
|
|
129
|
+
case 'created_at':
|
|
130
|
+
// `donobu-api` only supports `started_at`, but that's essentially
|
|
131
|
+
// equivalent to `created_at`
|
|
132
|
+
params.set('sort_by', 'started_at');
|
|
133
|
+
break;
|
|
134
|
+
default:
|
|
135
|
+
params.set('sort_by', query.sortBy);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
129
138
|
}
|
|
130
139
|
if (query.sortOrder) {
|
|
131
140
|
params.set('sort_order', query.sortOrder);
|
|
@@ -2,6 +2,17 @@ import type { EnvPick } from 'env-struct';
|
|
|
2
2
|
import type { env } from '../../envVars';
|
|
3
3
|
import { PersistencePluginRegistry } from '../PersistencePlugin';
|
|
4
4
|
import type { FlowsPersistence } from './FlowsPersistence';
|
|
5
|
+
/**
|
|
6
|
+
* A persistence layer paired with the `PERSISTENCE_PRIORITY` key it was
|
|
7
|
+
* created from. The key is shared across the flows/tests/suites registries:
|
|
8
|
+
* the same key in each registry refers to the same logical store, which is
|
|
9
|
+
* how cross-entity foreign keys are kept aligned (e.g. a flow's `testId`
|
|
10
|
+
* resolves to a test that lives in the same layer).
|
|
11
|
+
*/
|
|
12
|
+
export interface FlowsPersistenceLayer {
|
|
13
|
+
key: string;
|
|
14
|
+
persistence: FlowsPersistence;
|
|
15
|
+
}
|
|
5
16
|
export interface FlowsPersistenceRegistry {
|
|
6
17
|
/**
|
|
7
18
|
* Returns the primary persistence layer.
|
|
@@ -9,6 +20,17 @@ export interface FlowsPersistenceRegistry {
|
|
|
9
20
|
get(): Promise<FlowsPersistence>;
|
|
10
21
|
/** Returns a list of all valid persistence layers. Guaranteed to be non-empty. */
|
|
11
22
|
getAll(): Promise<FlowsPersistence[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Returns all valid layers paired with their `PERSISTENCE_PRIORITY` key.
|
|
25
|
+
* Used by callers that need to align writes across registries (e.g. write
|
|
26
|
+
* a flow into the same layer as the test it references).
|
|
27
|
+
*/
|
|
28
|
+
getEntries(): Promise<FlowsPersistenceLayer[]>;
|
|
29
|
+
/**
|
|
30
|
+
* Returns the persistence layer registered under the given
|
|
31
|
+
* `PERSISTENCE_PRIORITY` key, or undefined if no such layer exists.
|
|
32
|
+
*/
|
|
33
|
+
getByKey(key: string): Promise<FlowsPersistence | undefined>;
|
|
12
34
|
}
|
|
13
35
|
/**
|
|
14
36
|
* A factory class for creating FlowsPersistence instances. Persistence layers are constructed
|
|
@@ -19,7 +41,7 @@ export declare class FlowsPersistenceRegistryImpl implements FlowsPersistenceReg
|
|
|
19
41
|
/**
|
|
20
42
|
* Creates an instance with pre-built persistence layers.
|
|
21
43
|
*/
|
|
22
|
-
constructor(layers:
|
|
44
|
+
constructor(layers: FlowsPersistenceLayer[]);
|
|
23
45
|
/**
|
|
24
46
|
* Creates an instance by reading environment variables and eagerly constructing
|
|
25
47
|
* all applicable persistence layers.
|
|
@@ -31,5 +53,7 @@ export declare class FlowsPersistenceRegistryImpl implements FlowsPersistenceReg
|
|
|
31
53
|
get(): Promise<FlowsPersistence>;
|
|
32
54
|
/** Returns all persistence layers. Guaranteed to be non-empty. */
|
|
33
55
|
getAll(): Promise<FlowsPersistence[]>;
|
|
56
|
+
getEntries(): Promise<FlowsPersistenceLayer[]>;
|
|
57
|
+
getByKey(key: string): Promise<FlowsPersistence | undefined>;
|
|
34
58
|
}
|
|
35
59
|
//# sourceMappingURL=FlowsPersistenceRegistry.d.ts.map
|
|
@@ -32,21 +32,27 @@ class FlowsPersistenceRegistryImpl {
|
|
|
32
32
|
switch (key) {
|
|
33
33
|
case 'DONOBU':
|
|
34
34
|
if (donobuApiKey && donobuApiBaseUrl) {
|
|
35
|
-
layers.push(
|
|
35
|
+
layers.push({
|
|
36
|
+
key,
|
|
37
|
+
persistence: new FlowsPersistenceDonobuApi_1.FlowsPersistenceDonobuApi(donobuApiBaseUrl, donobuApiKey),
|
|
38
|
+
});
|
|
36
39
|
}
|
|
37
40
|
break;
|
|
38
41
|
case 'LOCAL':
|
|
39
|
-
layers.push(
|
|
42
|
+
layers.push({
|
|
43
|
+
key,
|
|
44
|
+
persistence: await FlowsPersistenceSqlite_1.FlowsPersistenceSqlite.create(await (0, DonobuSqliteDb_1.getDonobuSqliteDatabase)()),
|
|
45
|
+
});
|
|
40
46
|
break;
|
|
41
47
|
case 'RAM':
|
|
42
|
-
layers.push(new FlowsPersistenceVolatile_1.FlowsPersistenceVolatile());
|
|
48
|
+
layers.push({ key, persistence: new FlowsPersistenceVolatile_1.FlowsPersistenceVolatile() });
|
|
43
49
|
break;
|
|
44
50
|
default: {
|
|
45
51
|
const plugin = persistencePlugins.get(key);
|
|
46
52
|
if (plugin) {
|
|
47
53
|
const impl = await plugin.createFlowsPersistence();
|
|
48
54
|
if (impl) {
|
|
49
|
-
layers.push(impl);
|
|
55
|
+
layers.push({ key, persistence: impl });
|
|
50
56
|
}
|
|
51
57
|
}
|
|
52
58
|
break;
|
|
@@ -59,12 +65,18 @@ class FlowsPersistenceRegistryImpl {
|
|
|
59
65
|
* Returns the primary persistence layer.
|
|
60
66
|
*/
|
|
61
67
|
async get() {
|
|
62
|
-
return this.layers[0];
|
|
68
|
+
return this.layers[0].persistence;
|
|
63
69
|
}
|
|
64
70
|
/** Returns all persistence layers. Guaranteed to be non-empty. */
|
|
65
71
|
async getAll() {
|
|
72
|
+
return this.layers.map((layer) => layer.persistence);
|
|
73
|
+
}
|
|
74
|
+
async getEntries() {
|
|
66
75
|
return this.layers;
|
|
67
76
|
}
|
|
77
|
+
async getByKey(key) {
|
|
78
|
+
return this.layers.find((layer) => layer.key === key)?.persistence;
|
|
79
|
+
}
|
|
68
80
|
}
|
|
69
81
|
exports.FlowsPersistenceRegistryImpl = FlowsPersistenceRegistryImpl;
|
|
70
82
|
//# sourceMappingURL=FlowsPersistenceRegistry.js.map
|
|
@@ -2,17 +2,34 @@ import type { EnvPick } from 'env-struct';
|
|
|
2
2
|
import type { env } from '../../envVars';
|
|
3
3
|
import { PersistencePluginRegistry } from '../PersistencePlugin';
|
|
4
4
|
import type { SuitesPersistence } from './SuitesPersistence';
|
|
5
|
+
/**
|
|
6
|
+
* A persistence layer paired with its `PERSISTENCE_PRIORITY` key. See
|
|
7
|
+
* {@link FlowsPersistenceLayer} for why keys must align across registries.
|
|
8
|
+
*/
|
|
9
|
+
export interface SuitesPersistenceLayer {
|
|
10
|
+
key: string;
|
|
11
|
+
persistence: SuitesPersistence;
|
|
12
|
+
}
|
|
5
13
|
export interface SuitesPersistenceRegistry {
|
|
6
14
|
/** Returns the primary persistence layer. */
|
|
7
15
|
get(): Promise<SuitesPersistence>;
|
|
8
16
|
/** Returns all valid persistence layers. Guaranteed to be non-empty. */
|
|
9
17
|
getAll(): Promise<SuitesPersistence[]>;
|
|
18
|
+
/** Returns all valid layers paired with their `PERSISTENCE_PRIORITY` key. */
|
|
19
|
+
getEntries(): Promise<SuitesPersistenceLayer[]>;
|
|
20
|
+
/**
|
|
21
|
+
* Returns the persistence layer registered under the given
|
|
22
|
+
* `PERSISTENCE_PRIORITY` key, or undefined if no such layer exists.
|
|
23
|
+
*/
|
|
24
|
+
getByKey(key: string): Promise<SuitesPersistence | undefined>;
|
|
10
25
|
}
|
|
11
26
|
export declare class SuitesPersistenceRegistryImpl implements SuitesPersistenceRegistry {
|
|
12
27
|
private readonly layers;
|
|
13
|
-
constructor(layers:
|
|
28
|
+
constructor(layers: SuitesPersistenceLayer[]);
|
|
14
29
|
static fromEnvironment(environ: EnvPick<typeof env, 'DONOBU_API_BASE_URL' | 'DONOBU_API_KEY' | 'PERSISTENCE_PRIORITY'>, persistencePlugins?: PersistencePluginRegistry): Promise<SuitesPersistenceRegistryImpl>;
|
|
15
30
|
get(): Promise<SuitesPersistence>;
|
|
16
31
|
getAll(): Promise<SuitesPersistence[]>;
|
|
32
|
+
getEntries(): Promise<SuitesPersistenceLayer[]>;
|
|
33
|
+
getByKey(key: string): Promise<SuitesPersistence | undefined>;
|
|
17
34
|
}
|
|
18
35
|
//# sourceMappingURL=SuitesPersistenceRegistry.d.ts.map
|
|
@@ -21,21 +21,27 @@ class SuitesPersistenceRegistryImpl {
|
|
|
21
21
|
switch (key) {
|
|
22
22
|
case 'DONOBU':
|
|
23
23
|
if (donobuApiKey && donobuApiBaseUrl) {
|
|
24
|
-
layers.push(
|
|
24
|
+
layers.push({
|
|
25
|
+
key,
|
|
26
|
+
persistence: new SuitesPersistenceDonobuApi_1.SuitesPersistenceDonobuApi(donobuApiBaseUrl, donobuApiKey),
|
|
27
|
+
});
|
|
25
28
|
}
|
|
26
29
|
break;
|
|
27
30
|
case 'LOCAL':
|
|
28
|
-
layers.push(
|
|
31
|
+
layers.push({
|
|
32
|
+
key,
|
|
33
|
+
persistence: await SuitesPersistenceSqlite_1.SuitesPersistenceSqlite.create(await (0, DonobuSqliteDb_1.getDonobuSqliteDatabase)()),
|
|
34
|
+
});
|
|
29
35
|
break;
|
|
30
36
|
case 'RAM':
|
|
31
|
-
layers.push(new SuitesPersistenceVolatile_1.SuitesPersistenceVolatile());
|
|
37
|
+
layers.push({ key, persistence: new SuitesPersistenceVolatile_1.SuitesPersistenceVolatile() });
|
|
32
38
|
break;
|
|
33
39
|
default: {
|
|
34
40
|
const plugin = persistencePlugins.get(key);
|
|
35
41
|
if (plugin?.createSuitesPersistence) {
|
|
36
42
|
const impl = await plugin.createSuitesPersistence();
|
|
37
43
|
if (impl) {
|
|
38
|
-
layers.push(impl);
|
|
44
|
+
layers.push({ key, persistence: impl });
|
|
39
45
|
}
|
|
40
46
|
}
|
|
41
47
|
break;
|
|
@@ -45,11 +51,17 @@ class SuitesPersistenceRegistryImpl {
|
|
|
45
51
|
return new SuitesPersistenceRegistryImpl(layers);
|
|
46
52
|
}
|
|
47
53
|
async get() {
|
|
48
|
-
return this.layers[0];
|
|
54
|
+
return this.layers[0].persistence;
|
|
49
55
|
}
|
|
50
56
|
async getAll() {
|
|
57
|
+
return this.layers.map((layer) => layer.persistence);
|
|
58
|
+
}
|
|
59
|
+
async getEntries() {
|
|
51
60
|
return this.layers;
|
|
52
61
|
}
|
|
62
|
+
async getByKey(key) {
|
|
63
|
+
return this.layers.find((layer) => layer.key === key)?.persistence;
|
|
64
|
+
}
|
|
53
65
|
}
|
|
54
66
|
exports.SuitesPersistenceRegistryImpl = SuitesPersistenceRegistryImpl;
|
|
55
67
|
//# sourceMappingURL=SuitesPersistenceRegistry.js.map
|
|
@@ -3,15 +3,30 @@ import type { env } from '../../envVars';
|
|
|
3
3
|
import type { FlowsPersistenceRegistry } from '../flows/FlowsPersistenceRegistry';
|
|
4
4
|
import { PersistencePluginRegistry } from '../PersistencePlugin';
|
|
5
5
|
import type { TestsPersistence } from './TestsPersistence';
|
|
6
|
+
/**
|
|
7
|
+
* A persistence layer paired with its `PERSISTENCE_PRIORITY` key. See
|
|
8
|
+
* {@link FlowsPersistenceLayer} for why keys must align across registries.
|
|
9
|
+
*/
|
|
10
|
+
export interface TestsPersistenceLayer {
|
|
11
|
+
key: string;
|
|
12
|
+
persistence: TestsPersistence;
|
|
13
|
+
}
|
|
6
14
|
export interface TestsPersistenceRegistry {
|
|
7
15
|
/** Returns the primary persistence layer. */
|
|
8
16
|
get(): Promise<TestsPersistence>;
|
|
9
17
|
/** Returns all valid persistence layers. Guaranteed to be non-empty. */
|
|
10
18
|
getAll(): Promise<TestsPersistence[]>;
|
|
19
|
+
/** Returns all valid layers paired with their `PERSISTENCE_PRIORITY` key. */
|
|
20
|
+
getEntries(): Promise<TestsPersistenceLayer[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Returns the persistence layer registered under the given
|
|
23
|
+
* `PERSISTENCE_PRIORITY` key, or undefined if no such layer exists.
|
|
24
|
+
*/
|
|
25
|
+
getByKey(key: string): Promise<TestsPersistence | undefined>;
|
|
11
26
|
}
|
|
12
27
|
export declare class TestsPersistenceRegistryImpl implements TestsPersistenceRegistry {
|
|
13
28
|
private readonly layers;
|
|
14
|
-
constructor(layers:
|
|
29
|
+
constructor(layers: TestsPersistenceLayer[]);
|
|
15
30
|
static fromEnvironment(environ: EnvPick<typeof env, 'DONOBU_API_BASE_URL' | 'DONOBU_API_KEY' | 'PERSISTENCE_PRIORITY'>,
|
|
16
31
|
/**
|
|
17
32
|
* Flows registry — used by the volatile tests layer to compute
|
|
@@ -22,5 +37,7 @@ export declare class TestsPersistenceRegistryImpl implements TestsPersistenceReg
|
|
|
22
37
|
flowsRegistry?: FlowsPersistenceRegistry, persistencePlugins?: PersistencePluginRegistry): Promise<TestsPersistenceRegistryImpl>;
|
|
23
38
|
get(): Promise<TestsPersistence>;
|
|
24
39
|
getAll(): Promise<TestsPersistence[]>;
|
|
40
|
+
getEntries(): Promise<TestsPersistenceLayer[]>;
|
|
41
|
+
getByKey(key: string): Promise<TestsPersistence | undefined>;
|
|
25
42
|
}
|
|
26
43
|
//# sourceMappingURL=TestsPersistenceRegistry.d.ts.map
|