@scenar/sdk 0.0.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/LICENSE +190 -0
- package/author/createScenario.d.ts +23 -0
- package/author/createScenario.d.ts.map +1 -0
- package/author/createScenario.js +47 -0
- package/author/createScenario.js.map +1 -0
- package/author/types.d.ts +70 -0
- package/author/types.d.ts.map +1 -0
- package/author/types.js +2 -0
- package/author/types.js.map +1 -0
- package/index.d.ts +8 -0
- package/index.d.ts.map +1 -0
- package/index.js +10 -0
- package/index.js.map +1 -0
- package/package.json +32 -0
- package/proto/action-mapper.d.ts +15 -0
- package/proto/action-mapper.d.ts.map +1 -0
- package/proto/action-mapper.js +69 -0
- package/proto/action-mapper.js.map +1 -0
- package/proto/errors.d.ts +11 -0
- package/proto/errors.d.ts.map +1 -0
- package/proto/errors.js +16 -0
- package/proto/errors.js.map +1 -0
- package/proto/load-scenario.d.ts +25 -0
- package/proto/load-scenario.d.ts.map +1 -0
- package/proto/load-scenario.js +51 -0
- package/proto/load-scenario.js.map +1 -0
- package/proto/proto-types.d.ts +97 -0
- package/proto/proto-types.d.ts.map +1 -0
- package/proto/proto-types.js +23 -0
- package/proto/proto-types.js.map +1 -0
- package/src/__tests__/action-mapper.test.ts +195 -0
- package/src/__tests__/createScenario.test.ts +108 -0
- package/src/__tests__/load-scenario.test.ts +187 -0
- package/src/author/createScenario.ts +64 -0
- package/src/author/types.ts +76 -0
- package/src/index.ts +30 -0
- package/src/proto/action-mapper.ts +82 -0
- package/src/proto/errors.ts +16 -0
- package/src/proto/load-scenario.ts +81 -0
- package/src/proto/proto-types.ts +93 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural types mirroring the proto-generated TypeScript stubs.
|
|
3
|
+
*
|
|
4
|
+
* The SDK accepts values matching these shapes via structural subtyping.
|
|
5
|
+
* This avoids a hard dependency on the generated stubs — consumers
|
|
6
|
+
* pass proto-generated objects directly and TypeScript validates
|
|
7
|
+
* compatibility structurally.
|
|
8
|
+
*
|
|
9
|
+
* Field names match protoc-gen-es v2 output (camelCase).
|
|
10
|
+
*/
|
|
11
|
+
/** Mirrors generated `ActionType` enum values. */
|
|
12
|
+
export declare const PROTO_ACTION_TYPE: {
|
|
13
|
+
readonly unspecified: 0;
|
|
14
|
+
readonly set_cursor: 1;
|
|
15
|
+
readonly clear_cursor: 2;
|
|
16
|
+
readonly click: 3;
|
|
17
|
+
readonly type: 4;
|
|
18
|
+
readonly hover: 5;
|
|
19
|
+
readonly drag: 6;
|
|
20
|
+
readonly scroll_to: 7;
|
|
21
|
+
readonly viewport_transition: 8;
|
|
22
|
+
};
|
|
23
|
+
export type ProtoActionTypeValue = (typeof PROTO_ACTION_TYPE)[keyof typeof PROTO_ACTION_TYPE];
|
|
24
|
+
/** Structural shape of a proto `ViewportConfig` message. */
|
|
25
|
+
export interface ProtoViewportConfig {
|
|
26
|
+
readonly width: number;
|
|
27
|
+
readonly height: number;
|
|
28
|
+
}
|
|
29
|
+
/** Structural shape of a proto `TypeConfig` message. */
|
|
30
|
+
export interface ProtoTypeConfig {
|
|
31
|
+
readonly text: string;
|
|
32
|
+
readonly typeDelayMs: number;
|
|
33
|
+
}
|
|
34
|
+
/** Structural shape of a proto `HoverConfig` message. */
|
|
35
|
+
export interface ProtoHoverConfig {
|
|
36
|
+
readonly hoverDurationMs: number;
|
|
37
|
+
}
|
|
38
|
+
/** Structural shape of a proto `DragConfig` message. */
|
|
39
|
+
export interface ProtoDragConfig {
|
|
40
|
+
readonly dragTarget: string;
|
|
41
|
+
}
|
|
42
|
+
/** Structural shape of a proto `ViewportTransitionConfig` message. */
|
|
43
|
+
export interface ProtoViewportTransitionConfig {
|
|
44
|
+
readonly viewportZoom: number;
|
|
45
|
+
readonly viewportReset: boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Structural shape of the proto `StepAction.config` oneof.
|
|
49
|
+
* Matches protoc-gen-es v2's discriminated union output.
|
|
50
|
+
*/
|
|
51
|
+
export type ProtoStepActionConfig = {
|
|
52
|
+
case: "clickConfig";
|
|
53
|
+
value: object;
|
|
54
|
+
} | {
|
|
55
|
+
case: "typeConfig";
|
|
56
|
+
value: ProtoTypeConfig;
|
|
57
|
+
} | {
|
|
58
|
+
case: "hoverConfig";
|
|
59
|
+
value: ProtoHoverConfig;
|
|
60
|
+
} | {
|
|
61
|
+
case: "dragConfig";
|
|
62
|
+
value: ProtoDragConfig;
|
|
63
|
+
} | {
|
|
64
|
+
case: "scrollToConfig";
|
|
65
|
+
value: object;
|
|
66
|
+
} | {
|
|
67
|
+
case: "viewportTransitionConfig";
|
|
68
|
+
value: ProtoViewportTransitionConfig;
|
|
69
|
+
} | {
|
|
70
|
+
case: undefined;
|
|
71
|
+
value?: undefined;
|
|
72
|
+
};
|
|
73
|
+
/** Structural shape of a proto `StepAction` message. */
|
|
74
|
+
export interface ProtoStepAction {
|
|
75
|
+
readonly atPercent: number;
|
|
76
|
+
readonly type: number;
|
|
77
|
+
readonly target: string;
|
|
78
|
+
readonly config: ProtoStepActionConfig;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Structural shape of a proto `Step` message.
|
|
82
|
+
* `props` is `JsonObject` (protoc-gen-es v2 auto-converts `Struct`).
|
|
83
|
+
*/
|
|
84
|
+
export interface ProtoStep {
|
|
85
|
+
readonly view: string;
|
|
86
|
+
readonly delayMs: number;
|
|
87
|
+
readonly caption: string;
|
|
88
|
+
readonly narrationText: string;
|
|
89
|
+
readonly props?: Record<string, unknown>;
|
|
90
|
+
readonly interactions: readonly ProtoStepAction[];
|
|
91
|
+
}
|
|
92
|
+
/** Structural shape of the proto `Scenario` message. */
|
|
93
|
+
export interface ProtoScenario {
|
|
94
|
+
readonly viewport?: ProtoViewportConfig;
|
|
95
|
+
readonly steps: readonly ProtoStep[];
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=proto-types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proto-types.d.ts","sourceRoot":"","sources":["../../src/proto/proto-types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,kDAAkD;AAClD,eAAO,MAAM,iBAAiB;;;;;;;;;;CAUpB,CAAC;AAEX,MAAM,MAAM,oBAAoB,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,OAAO,iBAAiB,CAAC,CAAC;AAE9F,4DAA4D;AAC5D,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,wDAAwD;AACxD,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,yDAAyD;AACzD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CAClC;AAED,wDAAwD;AACxD,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,sEAAsE;AACtE,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;CACjC;AAED;;;GAGG;AACH,MAAM,MAAM,qBAAqB,GAC7B;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACtC;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,KAAK,EAAE,eAAe,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,EAAE,gBAAgB,CAAA;CAAE,GAChD;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,KAAK,EAAE,eAAe,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,0BAA0B,CAAC;IAAC,KAAK,EAAE,6BAA6B,CAAA;CAAE,GAC1E;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,CAAC,EAAE,SAAS,CAAA;CAAE,CAAC;AAE3C,wDAAwD;AACxD,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAC;CACxC;AAED;;;GAGG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzC,QAAQ,CAAC,YAAY,EAAE,SAAS,eAAe,EAAE,CAAC;CACnD;AAED,wDAAwD;AACxD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAC;IACxC,QAAQ,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,CAAC;CACtC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural types mirroring the proto-generated TypeScript stubs.
|
|
3
|
+
*
|
|
4
|
+
* The SDK accepts values matching these shapes via structural subtyping.
|
|
5
|
+
* This avoids a hard dependency on the generated stubs — consumers
|
|
6
|
+
* pass proto-generated objects directly and TypeScript validates
|
|
7
|
+
* compatibility structurally.
|
|
8
|
+
*
|
|
9
|
+
* Field names match protoc-gen-es v2 output (camelCase).
|
|
10
|
+
*/
|
|
11
|
+
/** Mirrors generated `ActionType` enum values. */
|
|
12
|
+
export const PROTO_ACTION_TYPE = {
|
|
13
|
+
unspecified: 0,
|
|
14
|
+
set_cursor: 1,
|
|
15
|
+
clear_cursor: 2,
|
|
16
|
+
click: 3,
|
|
17
|
+
type: 4,
|
|
18
|
+
hover: 5,
|
|
19
|
+
drag: 6,
|
|
20
|
+
scroll_to: 7,
|
|
21
|
+
viewport_transition: 8,
|
|
22
|
+
};
|
|
23
|
+
//# sourceMappingURL=proto-types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proto-types.js","sourceRoot":"","sources":["../../src/proto/proto-types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,kDAAkD;AAClD,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,WAAW,EAAE,CAAC;IACd,UAAU,EAAE,CAAC;IACb,YAAY,EAAE,CAAC;IACf,KAAK,EAAE,CAAC;IACR,IAAI,EAAE,CAAC;IACP,KAAK,EAAE,CAAC;IACR,IAAI,EAAE,CAAC;IACP,SAAS,EAAE,CAAC;IACZ,mBAAmB,EAAE,CAAC;CACd,CAAC"}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { mapProtoAction } from "../proto/action-mapper.js";
|
|
3
|
+
import { PROTO_ACTION_TYPE, type ProtoStepAction } from "../proto/proto-types.js";
|
|
4
|
+
|
|
5
|
+
function makeAction(overrides: Partial<ProtoStepAction>): ProtoStepAction {
|
|
6
|
+
return {
|
|
7
|
+
atPercent: 0.5,
|
|
8
|
+
type: PROTO_ACTION_TYPE.click,
|
|
9
|
+
target: "btn",
|
|
10
|
+
config: { case: undefined },
|
|
11
|
+
...overrides,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("mapProtoAction", () => {
|
|
16
|
+
it("maps set_cursor", () => {
|
|
17
|
+
const result = mapProtoAction(
|
|
18
|
+
makeAction({ type: PROTO_ACTION_TYPE.set_cursor, target: "el" }),
|
|
19
|
+
"test",
|
|
20
|
+
);
|
|
21
|
+
expect(result.type).toBe("set_cursor");
|
|
22
|
+
expect(result.target).toBe("el");
|
|
23
|
+
expect(result.atPercent).toBe(0.5);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("maps clear_cursor", () => {
|
|
27
|
+
const result = mapProtoAction(
|
|
28
|
+
makeAction({ type: PROTO_ACTION_TYPE.clear_cursor, target: "" }),
|
|
29
|
+
"test",
|
|
30
|
+
);
|
|
31
|
+
expect(result.type).toBe("clear_cursor");
|
|
32
|
+
expect(result.target).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("maps click", () => {
|
|
36
|
+
const result = mapProtoAction(
|
|
37
|
+
makeAction({
|
|
38
|
+
type: PROTO_ACTION_TYPE.click,
|
|
39
|
+
config: { case: "clickConfig", value: {} },
|
|
40
|
+
}),
|
|
41
|
+
"test",
|
|
42
|
+
);
|
|
43
|
+
expect(result.type).toBe("click");
|
|
44
|
+
expect(result.target).toBe("btn");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("maps type with typeConfig", () => {
|
|
48
|
+
const result = mapProtoAction(
|
|
49
|
+
makeAction({
|
|
50
|
+
type: PROTO_ACTION_TYPE.type,
|
|
51
|
+
config: {
|
|
52
|
+
case: "typeConfig",
|
|
53
|
+
value: { text: "hello", typeDelayMs: 80 },
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
"test",
|
|
57
|
+
);
|
|
58
|
+
expect(result.type).toBe("type");
|
|
59
|
+
expect(result.text).toBe("hello");
|
|
60
|
+
expect(result.typeDelay).toBe(80);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("maps type with zero typeDelayMs as undefined (engine default)", () => {
|
|
64
|
+
const result = mapProtoAction(
|
|
65
|
+
makeAction({
|
|
66
|
+
type: PROTO_ACTION_TYPE.type,
|
|
67
|
+
config: {
|
|
68
|
+
case: "typeConfig",
|
|
69
|
+
value: { text: "hi", typeDelayMs: 0 },
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
"test",
|
|
73
|
+
);
|
|
74
|
+
expect(result.typeDelay).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("maps hover with hoverConfig", () => {
|
|
78
|
+
const result = mapProtoAction(
|
|
79
|
+
makeAction({
|
|
80
|
+
type: PROTO_ACTION_TYPE.hover,
|
|
81
|
+
config: {
|
|
82
|
+
case: "hoverConfig",
|
|
83
|
+
value: { hoverDurationMs: 2000 },
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
"test",
|
|
87
|
+
);
|
|
88
|
+
expect(result.type).toBe("hover");
|
|
89
|
+
expect(result.hoverDuration).toBe(2000);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("maps hover with zero hoverDurationMs as undefined (engine default)", () => {
|
|
93
|
+
const result = mapProtoAction(
|
|
94
|
+
makeAction({
|
|
95
|
+
type: PROTO_ACTION_TYPE.hover,
|
|
96
|
+
config: {
|
|
97
|
+
case: "hoverConfig",
|
|
98
|
+
value: { hoverDurationMs: 0 },
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
"test",
|
|
102
|
+
);
|
|
103
|
+
expect(result.hoverDuration).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("maps drag with dragConfig", () => {
|
|
107
|
+
const result = mapProtoAction(
|
|
108
|
+
makeAction({
|
|
109
|
+
type: PROTO_ACTION_TYPE.drag,
|
|
110
|
+
target: "card",
|
|
111
|
+
config: {
|
|
112
|
+
case: "dragConfig",
|
|
113
|
+
value: { dragTarget: "slot" },
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
"test",
|
|
117
|
+
);
|
|
118
|
+
expect(result.type).toBe("drag");
|
|
119
|
+
expect(result.target).toBe("card");
|
|
120
|
+
expect(result.dragTarget).toBe("slot");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("maps scroll_to", () => {
|
|
124
|
+
const result = mapProtoAction(
|
|
125
|
+
makeAction({
|
|
126
|
+
type: PROTO_ACTION_TYPE.scroll_to,
|
|
127
|
+
target: "section",
|
|
128
|
+
config: { case: "scrollToConfig", value: {} },
|
|
129
|
+
}),
|
|
130
|
+
"test",
|
|
131
|
+
);
|
|
132
|
+
expect(result.type).toBe("scroll_to");
|
|
133
|
+
expect(result.target).toBe("section");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("maps viewport_transition with config", () => {
|
|
137
|
+
const result = mapProtoAction(
|
|
138
|
+
makeAction({
|
|
139
|
+
type: PROTO_ACTION_TYPE.viewport_transition,
|
|
140
|
+
target: "panel",
|
|
141
|
+
config: {
|
|
142
|
+
case: "viewportTransitionConfig",
|
|
143
|
+
value: { viewportZoom: 2.0, viewportReset: false },
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
"test",
|
|
147
|
+
);
|
|
148
|
+
expect(result.type).toBe("viewport_transition");
|
|
149
|
+
expect(result.viewportZoom).toBe(2.0);
|
|
150
|
+
expect(result.viewportReset).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("maps viewport_transition with reset", () => {
|
|
154
|
+
const result = mapProtoAction(
|
|
155
|
+
makeAction({
|
|
156
|
+
type: PROTO_ACTION_TYPE.viewport_transition,
|
|
157
|
+
config: {
|
|
158
|
+
case: "viewportTransitionConfig",
|
|
159
|
+
value: { viewportZoom: 0, viewportReset: true },
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
"test",
|
|
163
|
+
);
|
|
164
|
+
expect(result.viewportReset).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("throws InvalidScenarioError for unspecified action type", () => {
|
|
168
|
+
expect(() =>
|
|
169
|
+
mapProtoAction(
|
|
170
|
+
makeAction({ type: PROTO_ACTION_TYPE.unspecified }),
|
|
171
|
+
"spec.steps[2].interactions[0]",
|
|
172
|
+
),
|
|
173
|
+
).toThrow(/unknown ActionType value 0/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("throws InvalidScenarioError for unknown numeric type", () => {
|
|
177
|
+
expect(() =>
|
|
178
|
+
mapProtoAction(makeAction({ type: 99 }), "spec.steps[0].interactions[0]"),
|
|
179
|
+
).toThrow(/unknown ActionType value 99/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("error includes the provided path", () => {
|
|
183
|
+
try {
|
|
184
|
+
mapProtoAction(
|
|
185
|
+
makeAction({ type: PROTO_ACTION_TYPE.unspecified }),
|
|
186
|
+
"spec.steps[3].interactions[1]",
|
|
187
|
+
);
|
|
188
|
+
expect.fail("should have thrown");
|
|
189
|
+
} catch (e) {
|
|
190
|
+
expect((e as { path: string }).path).toBe(
|
|
191
|
+
"spec.steps[3].interactions[1].type",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createScenario } from "../author/createScenario.js";
|
|
3
|
+
|
|
4
|
+
interface SettingsProps {
|
|
5
|
+
readonly org: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface FormProps {
|
|
9
|
+
readonly defaultName: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function SettingsView(_props: SettingsProps): unknown {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function FormView(_props: FormProps): unknown {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("createScenario", () => {
|
|
21
|
+
it("builds an AuthoredScenario from valid input", () => {
|
|
22
|
+
const scenario = createScenario({
|
|
23
|
+
viewport: { width: 896, height: 540 },
|
|
24
|
+
views: { settings: SettingsView, form: FormView },
|
|
25
|
+
steps: [
|
|
26
|
+
{ view: "settings", delayMs: 0, caption: "Start", props: { org: "acme" } },
|
|
27
|
+
{
|
|
28
|
+
view: "form",
|
|
29
|
+
delayMs: 1500,
|
|
30
|
+
caption: "Fill",
|
|
31
|
+
narrationText: "Enter your key name.",
|
|
32
|
+
props: { defaultName: "demo" },
|
|
33
|
+
interactions: [
|
|
34
|
+
{ atPercent: 0.2, type: "type", target: "name-input", text: "quickstart" },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(scenario.viewport).toEqual({ width: 896, height: 540 });
|
|
41
|
+
expect(scenario.steps).toHaveLength(2);
|
|
42
|
+
|
|
43
|
+
expect(scenario.steps[0]!.delayMs).toBe(0);
|
|
44
|
+
expect(scenario.steps[0]!.data.view).toBe("settings");
|
|
45
|
+
expect(scenario.steps[0]!.data.props).toEqual({ org: "acme" });
|
|
46
|
+
expect(scenario.steps[0]!.caption).toBe("Start");
|
|
47
|
+
expect(scenario.steps[0]!.interactions).toBeUndefined();
|
|
48
|
+
|
|
49
|
+
expect(scenario.steps[1]!.delayMs).toBe(1500);
|
|
50
|
+
expect(scenario.steps[1]!.data.view).toBe("form");
|
|
51
|
+
expect(scenario.steps[1]!.data.props).toEqual({ defaultName: "demo" });
|
|
52
|
+
expect(scenario.steps[1]!.narration).toBe("Enter your key name.");
|
|
53
|
+
expect(scenario.steps[1]!.interactions).toHaveLength(1);
|
|
54
|
+
expect(scenario.steps[1]!.interactions![0]!.type).toBe("type");
|
|
55
|
+
expect(scenario.steps[1]!.interactions![0]!.text).toBe("quickstart");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("preserves view registry on the output", () => {
|
|
59
|
+
const views = { settings: SettingsView };
|
|
60
|
+
const scenario = createScenario({
|
|
61
|
+
views,
|
|
62
|
+
steps: [{ view: "settings", delayMs: 0, props: { org: "a" } }],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(scenario.views).toBe(views);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("accepts undefined viewport", () => {
|
|
69
|
+
const scenario = createScenario({
|
|
70
|
+
views: { settings: SettingsView },
|
|
71
|
+
steps: [{ view: "settings", delayMs: 0, props: { org: "a" } }],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(scenario.viewport).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("throws when steps array is empty", () => {
|
|
78
|
+
expect(() =>
|
|
79
|
+
createScenario({
|
|
80
|
+
views: { settings: SettingsView },
|
|
81
|
+
steps: [],
|
|
82
|
+
}),
|
|
83
|
+
).toThrow("steps array must not be empty");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("throws when a step references an unregistered view", () => {
|
|
87
|
+
expect(() =>
|
|
88
|
+
createScenario({
|
|
89
|
+
views: { settings: SettingsView },
|
|
90
|
+
steps: [
|
|
91
|
+
// @ts-expect-error — intentional runtime error test
|
|
92
|
+
{ view: "nonexistent", delayMs: 0, props: {} },
|
|
93
|
+
],
|
|
94
|
+
}),
|
|
95
|
+
).toThrow(/step\[0\]\.view "nonexistent" is not in the views registry/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("maps narrationText to the engine narration field", () => {
|
|
99
|
+
const scenario = createScenario({
|
|
100
|
+
views: { settings: SettingsView },
|
|
101
|
+
steps: [
|
|
102
|
+
{ view: "settings", delayMs: 0, narrationText: "Hello", props: { org: "a" } },
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(scenario.steps[0]!.narration).toBe("Hello");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { loadScenarioFromProto } from "../proto/load-scenario.js";
|
|
3
|
+
import { InvalidScenarioError } from "../proto/errors.js";
|
|
4
|
+
import { PROTO_ACTION_TYPE, type ProtoScenario } from "../proto/proto-types.js";
|
|
5
|
+
|
|
6
|
+
interface SettingsProps {
|
|
7
|
+
readonly org: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface FormProps {
|
|
11
|
+
readonly defaultName: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function SettingsView(_props: SettingsProps): unknown {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function FormView(_props: FormProps): unknown {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const views = { settings: SettingsView, form: FormView };
|
|
23
|
+
|
|
24
|
+
function makeValidScenario(): ProtoScenario {
|
|
25
|
+
return {
|
|
26
|
+
viewport: { width: 896, height: 540 },
|
|
27
|
+
steps: [
|
|
28
|
+
{
|
|
29
|
+
view: "settings",
|
|
30
|
+
delayMs: 0,
|
|
31
|
+
caption: "Start here",
|
|
32
|
+
narrationText: "Welcome.",
|
|
33
|
+
props: { org: "acme" },
|
|
34
|
+
interactions: [],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
view: "form",
|
|
38
|
+
delayMs: 1500,
|
|
39
|
+
caption: "Fill the form",
|
|
40
|
+
narrationText: "",
|
|
41
|
+
props: { defaultName: "demo" },
|
|
42
|
+
interactions: [
|
|
43
|
+
{
|
|
44
|
+
atPercent: 0.2,
|
|
45
|
+
type: PROTO_ACTION_TYPE.type,
|
|
46
|
+
target: "name-input",
|
|
47
|
+
config: {
|
|
48
|
+
case: "typeConfig" as const,
|
|
49
|
+
value: { text: "quickstart", typeDelayMs: 0 },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("loadScenarioFromProto", () => {
|
|
59
|
+
it("loads a valid proto scenario into an AuthoredScenario", () => {
|
|
60
|
+
const scenario = loadScenarioFromProto(makeValidScenario(), { views });
|
|
61
|
+
|
|
62
|
+
expect(scenario.viewport).toEqual({ width: 896, height: 540 });
|
|
63
|
+
expect(scenario.steps).toHaveLength(2);
|
|
64
|
+
expect(scenario.views).toBe(views);
|
|
65
|
+
|
|
66
|
+
expect(scenario.steps[0]!.data.view).toBe("settings");
|
|
67
|
+
expect(scenario.steps[0]!.delayMs).toBe(0);
|
|
68
|
+
expect(scenario.steps[0]!.caption).toBe("Start here");
|
|
69
|
+
expect(scenario.steps[0]!.narration).toBe("Welcome.");
|
|
70
|
+
expect(scenario.steps[0]!.interactions).toBeUndefined();
|
|
71
|
+
|
|
72
|
+
expect(scenario.steps[1]!.data.view).toBe("form");
|
|
73
|
+
expect(scenario.steps[1]!.delayMs).toBe(1500);
|
|
74
|
+
expect(scenario.steps[1]!.interactions).toHaveLength(1);
|
|
75
|
+
expect(scenario.steps[1]!.interactions![0]!.type).toBe("type");
|
|
76
|
+
expect(scenario.steps[1]!.interactions![0]!.text).toBe("quickstart");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("passes through props from proto Struct as plain object", () => {
|
|
80
|
+
const scenario = loadScenarioFromProto(makeValidScenario(), { views });
|
|
81
|
+
|
|
82
|
+
expect(scenario.steps[0]!.data.props).toEqual({ org: "acme" });
|
|
83
|
+
expect(scenario.steps[1]!.data.props).toEqual({ defaultName: "demo" });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("defaults props to empty object when proto props is undefined", () => {
|
|
87
|
+
const proto = makeValidScenario();
|
|
88
|
+
const step = { ...proto.steps[0]!, props: undefined };
|
|
89
|
+
const modified: ProtoScenario = {
|
|
90
|
+
...proto,
|
|
91
|
+
steps: [step],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const scenario = loadScenarioFromProto(modified, { views });
|
|
95
|
+
expect(scenario.steps[0]!.data.props).toEqual({});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("handles scenario with no viewport", () => {
|
|
99
|
+
const proto = makeValidScenario();
|
|
100
|
+
const modified: ProtoScenario = {
|
|
101
|
+
...proto,
|
|
102
|
+
viewport: undefined,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const scenario = loadScenarioFromProto(modified, { views });
|
|
106
|
+
expect(scenario.viewport).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("throws when steps array is empty", () => {
|
|
110
|
+
const proto: ProtoScenario = { steps: [] };
|
|
111
|
+
|
|
112
|
+
expect(() => loadScenarioFromProto(proto, { views })).toThrow(
|
|
113
|
+
/steps array must not be empty/,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("throws when a step references an unregistered view", () => {
|
|
118
|
+
const modified: ProtoScenario = {
|
|
119
|
+
steps: [
|
|
120
|
+
{
|
|
121
|
+
view: "unknown-view",
|
|
122
|
+
delayMs: 0,
|
|
123
|
+
caption: "",
|
|
124
|
+
narrationText: "",
|
|
125
|
+
interactions: [],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
loadScenarioFromProto(modified, { views });
|
|
132
|
+
expect.fail("should have thrown");
|
|
133
|
+
} catch (e) {
|
|
134
|
+
const err = e as InvalidScenarioError;
|
|
135
|
+
expect(err.path).toBe("steps[0].view");
|
|
136
|
+
expect(err.reason).toContain("unknown-view");
|
|
137
|
+
expect(err.reason).toContain("settings");
|
|
138
|
+
expect(err.reason).toContain("form");
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("throws when an action has unspecified type", () => {
|
|
143
|
+
const proto = makeValidScenario();
|
|
144
|
+
const modified: ProtoScenario = {
|
|
145
|
+
...proto,
|
|
146
|
+
steps: [
|
|
147
|
+
{
|
|
148
|
+
...proto.steps[0]!,
|
|
149
|
+
interactions: [
|
|
150
|
+
{
|
|
151
|
+
atPercent: 0.5,
|
|
152
|
+
type: PROTO_ACTION_TYPE.unspecified,
|
|
153
|
+
target: "x",
|
|
154
|
+
config: { case: undefined },
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
loadScenarioFromProto(modified, { views });
|
|
163
|
+
expect.fail("should have thrown");
|
|
164
|
+
} catch (e) {
|
|
165
|
+
const err = e as InvalidScenarioError;
|
|
166
|
+
expect(err.path).toBe("steps[0].interactions[0].type");
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("omits empty narration and caption strings", () => {
|
|
171
|
+
const modified: ProtoScenario = {
|
|
172
|
+
steps: [
|
|
173
|
+
{
|
|
174
|
+
view: "settings",
|
|
175
|
+
delayMs: 0,
|
|
176
|
+
caption: "",
|
|
177
|
+
narrationText: "",
|
|
178
|
+
interactions: [],
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const scenario = loadScenarioFromProto(modified, { views });
|
|
184
|
+
expect(scenario.steps[0]!.caption).toBeUndefined();
|
|
185
|
+
expect(scenario.steps[0]!.narration).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ScenarioStep } from "@scenar/core";
|
|
2
|
+
import type {
|
|
3
|
+
AuthoredScenario,
|
|
4
|
+
AuthoredStepData,
|
|
5
|
+
ScenarioInput,
|
|
6
|
+
StepInput,
|
|
7
|
+
ViewRegistry,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build a type-safe scenario from a view registry and step definitions.
|
|
12
|
+
*
|
|
13
|
+
* Each step's `props` is statically validated against the component
|
|
14
|
+
* registered under its `view` name — no `any`, no `JsonObject`.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const scenario = createScenario({
|
|
18
|
+
* viewport: { width: 896, height: 540 },
|
|
19
|
+
* views: { settings: SettingsView, form: FormView },
|
|
20
|
+
* steps: [
|
|
21
|
+
* { view: "settings", delayMs: 0, props: { org: "acme" } },
|
|
22
|
+
* { view: "form", delayMs: 1500, props: { defaultName: "demo" } },
|
|
23
|
+
* ],
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @throws {Error} if `steps` is empty or a step references an
|
|
28
|
+
* unregistered view name.
|
|
29
|
+
*/
|
|
30
|
+
export function createScenario<Views extends ViewRegistry>(
|
|
31
|
+
input: ScenarioInput<Views>,
|
|
32
|
+
): AuthoredScenario<Views> {
|
|
33
|
+
if (input.steps.length === 0) {
|
|
34
|
+
throw new Error("[scenar] createScenario: steps array must not be empty.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const viewNames = new Set(Object.keys(input.views));
|
|
38
|
+
const steps: ScenarioStep<AuthoredStepData<Views>>[] = [];
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < input.steps.length; i++) {
|
|
41
|
+
const step = input.steps[i] as StepInput<Views>;
|
|
42
|
+
|
|
43
|
+
if (!viewNames.has(step.view)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`[scenar] createScenario: step[${i}].view "${step.view}" is not in the views registry. ` +
|
|
46
|
+
`Registered views: ${[...viewNames].join(", ")}.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
steps.push({
|
|
51
|
+
delayMs: step.delayMs,
|
|
52
|
+
data: { view: step.view, props: step.props } as AuthoredStepData<Views>,
|
|
53
|
+
caption: step.caption,
|
|
54
|
+
narration: step.narrationText,
|
|
55
|
+
interactions: step.interactions,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
viewport: input.viewport,
|
|
61
|
+
views: input.views,
|
|
62
|
+
steps,
|
|
63
|
+
};
|
|
64
|
+
}
|