create-minlang-app 0.3.0 → 0.4.0
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/bin.mjs +1 -1
- package/package.json +1 -1
- package/template/AGENTS.md +12 -1
- package/template/__APP_NAME__.ml +110 -176
- package/template/app/e2e/01-welcome.spec.ts +36 -0
- package/template/app/e2e/02-a11y.spec.ts +12 -16
- package/template/app/seed.ts +23 -6
- package/template/app/tests/welcome-flow.test.ts +77 -0
- package/template/gitattributes +4 -0
- package/template/app/e2e/01-board.spec.ts +0 -68
- package/template/app/tests/task-flow.test.ts +0 -111
package/bin.mjs
CHANGED
|
@@ -65,7 +65,7 @@ Next steps:
|
|
|
65
65
|
pnpm --dir app install
|
|
66
66
|
make dev # http://localhost:3111
|
|
67
67
|
|
|
68
|
-
Your whole app lives in ${name}.ml (it starts as a
|
|
68
|
+
Your whole app lives in ${name}.ml (it starts as a MinLang welcome tour — replace it).
|
|
69
69
|
Agent instructions: ${name}/AGENTS.md
|
|
70
70
|
Language rules: https://github.com/codeshift-ai-solutions/minlang-releases/releases/latest/download/minlang-language-bundle.md
|
|
71
71
|
Deploys: push to GitHub with org secrets VERCEL_TOKEN/VERCEL_ORG_ID set;
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -27,6 +27,17 @@ detector list, and the end-to-end web-app authoring guide. Highlights:
|
|
|
27
27
|
- Generate -> validate -> fix -> revalidate until zero violations, then
|
|
28
28
|
output. `ml1 validate` is the same gate CI runs.
|
|
29
29
|
|
|
30
|
+
## Language version & updates
|
|
31
|
+
|
|
32
|
+
This repo is pinned to a MinLang language version in `minlang.json`
|
|
33
|
+
(`languageBundleVersion`, written by `ml1 init`). Run `make update` (or
|
|
34
|
+
`ml1 update`) to pull a newer compiler + language. When the language has
|
|
35
|
+
moved on, update writes `MINLANG_MIGRATION.md` — a checklist of
|
|
36
|
+
old-form -> new-form recommendations. Read it, apply the changes to
|
|
37
|
+
`__APP_NAME__.ml`, run `make compile && make test` until green, then delete
|
|
38
|
+
it. `ml1 update` advances the pin for additive upgrades and never edits your
|
|
39
|
+
`.ml` itself.
|
|
40
|
+
|
|
30
41
|
## Commands
|
|
31
42
|
|
|
32
43
|
| Task | Command |
|
|
@@ -38,7 +49,7 @@ detector list, and the end-to-end web-app authoring guide. Highlights:
|
|
|
38
49
|
| Unit + generated test triads | `make test` |
|
|
39
50
|
| Lint custom skins (`app/skins/`) | `make lint-skins` (also runs in `make test`) |
|
|
40
51
|
| Dev server (http://localhost:3111) | `make dev` |
|
|
41
|
-
| Update toolchain + deps | `make update` |
|
|
52
|
+
| Update toolchain, language pin + deps | `make update` (writes MINLANG_MIGRATION.md if the language moved on) |
|
|
42
53
|
| Production build | `make build` |
|
|
43
54
|
| Browser tests (a11y, perf) | `make test-e2e` (installs the browser on first run) |
|
|
44
55
|
| Screen previews (`screen-previews/*.png`, all screens, mobile + desktop) | `make preview` |
|
package/template/__APP_NAME__.ml
CHANGED
|
@@ -1,240 +1,174 @@
|
|
|
1
|
-
|
|
1
|
+
// Welcome to MinLang — the starter app `ml1 init` / `npm create minlang-app`
|
|
2
|
+
// scaffolds. The WHOLE app is this one file: entities (data shape),
|
|
3
|
+
// constraints (the only place rules live), actions (pure create/set/delete),
|
|
4
|
+
// queries (read-only view-models), screens (declarative UI + copy), and
|
|
5
|
+
// tests (the success / failure / no-change triad). Edit this file, run
|
|
6
|
+
// `make compile`, and the generated app updates. Delete the demo below and
|
|
7
|
+
// model your own domain when you're ready.
|
|
8
|
+
|
|
9
|
+
// --- Navigation state -------------------------------------------------------
|
|
10
|
+
// A single Tour row drives which screen is active (see the `when` selectors).
|
|
11
|
+
entity Tour {
|
|
2
12
|
id: string req
|
|
3
|
-
|
|
13
|
+
page: enum(welcome, files, enhance, resources)
|
|
4
14
|
}
|
|
5
15
|
|
|
6
|
-
|
|
16
|
+
// --- A tiny live demo: a guestbook note -------------------------------------
|
|
17
|
+
// Shows the core loop — a form creates a row, a constraint guards it, a board
|
|
18
|
+
// lists it, and tests prove all three.
|
|
19
|
+
entity Note {
|
|
7
20
|
id: string req
|
|
8
|
-
|
|
21
|
+
greeting: string
|
|
9
22
|
}
|
|
10
23
|
|
|
11
|
-
|
|
24
|
+
// --- Seeded content shown as tables (rows live in app/seed.ts) --------------
|
|
25
|
+
entity FileGuide {
|
|
12
26
|
id: string req
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
status: enum(todo, doing, done)
|
|
16
|
-
assignee: string
|
|
17
|
-
created_at: string
|
|
18
|
-
actor_id: string
|
|
27
|
+
file: string
|
|
28
|
+
purpose: string
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
constraint AssigneeRequiredWhenDoing {
|
|
28
|
-
on: Task
|
|
29
|
-
validate: NOT(self.status == 'doing' AND self.assignee == '')
|
|
30
|
-
message: 'assignee is required when a task is doing'
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
constraint ProjectExists {
|
|
34
|
-
on: Task
|
|
35
|
-
validate: count(Project as p, p == self.project) == 1
|
|
36
|
-
message: 'project does not exist'
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
constraint ProjectNameRequired {
|
|
40
|
-
on: Project
|
|
41
|
-
validate: NOT(self.name == '')
|
|
42
|
-
message: 'project name is required'
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
constraint UniqueProjectName {
|
|
46
|
-
on: Project
|
|
47
|
-
validate: count(Project as p, p.name == name) == 0
|
|
48
|
-
message: 'a project with this name already exists'
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
action CreateProject(id: string, name: string, actor_id: string) {
|
|
52
|
-
on: Project
|
|
53
|
-
create(Project, { id: id, name: name })
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
action CreateTask(id: string, project: ref(Project), title: string, status: enum(todo, doing, done), assignee: string, created_at: string, actor_id: string) {
|
|
57
|
-
on: Task
|
|
58
|
-
create(Task, { id: id, project: project, title: title, status: status, assignee: assignee, created_at: created_at, actor_id: actor_id })
|
|
31
|
+
entity BuildStep {
|
|
32
|
+
id: string req
|
|
33
|
+
step: string
|
|
34
|
+
detail: string
|
|
59
35
|
}
|
|
60
36
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
37
|
+
entity Resource {
|
|
38
|
+
id: string req
|
|
39
|
+
topic: string
|
|
40
|
+
link: string
|
|
64
41
|
}
|
|
65
42
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
43
|
+
// --- Rules live in constraints, never in actions ----------------------------
|
|
44
|
+
constraint GreetingRequired {
|
|
45
|
+
on: Note
|
|
46
|
+
validate: NOT(self.greeting == '')
|
|
47
|
+
message: 'a greeting is required'
|
|
69
48
|
}
|
|
70
49
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
50
|
+
// --- Actions are pure mutation (create / set / delete) ----------------------
|
|
51
|
+
action AddNote(id: string, greeting: string, actor_id: string) {
|
|
52
|
+
on: Note
|
|
53
|
+
create(Note, { id: id, greeting: greeting })
|
|
74
54
|
}
|
|
75
55
|
|
|
76
|
-
action
|
|
77
|
-
on:
|
|
78
|
-
set(
|
|
56
|
+
action ShowWelcome(tour: ref(Tour), actor_id: string) {
|
|
57
|
+
on: Tour
|
|
58
|
+
set(page, 'welcome')
|
|
79
59
|
}
|
|
80
60
|
|
|
81
|
-
|
|
82
|
-
|
|
61
|
+
action ShowFiles(tour: ref(Tour), actor_id: string) {
|
|
62
|
+
on: Tour
|
|
63
|
+
set(page, 'files')
|
|
83
64
|
}
|
|
84
65
|
|
|
85
|
-
|
|
86
|
-
|
|
66
|
+
action ShowEnhance(tour: ref(Tour), actor_id: string) {
|
|
67
|
+
on: Tour
|
|
68
|
+
set(page, 'enhance')
|
|
87
69
|
}
|
|
88
70
|
|
|
89
|
-
|
|
90
|
-
|
|
71
|
+
action ShowResources(tour: ref(Tour), actor_id: string) {
|
|
72
|
+
on: Tour
|
|
73
|
+
set(page, 'resources')
|
|
91
74
|
}
|
|
92
75
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
hint "Create tasks, move them between statuses, and rename them."
|
|
97
|
-
board TaskBoard
|
|
98
|
-
primary { label "Add task" action CreateTask }
|
|
99
|
-
button { label "Move task" action MoveTask }
|
|
100
|
-
button { label "Rename task" action RenameTask }
|
|
101
|
-
button { label "Projects" action OpenProjects }
|
|
76
|
+
// --- Queries are read-only view-models --------------------------------------
|
|
77
|
+
query NoteList(tour: ref(Tour)) -> list<Note> {
|
|
78
|
+
from Note
|
|
102
79
|
}
|
|
103
80
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
title "Projects"
|
|
107
|
-
body "Every task belongs to a project."
|
|
108
|
-
board ProjectList
|
|
109
|
-
primary { label "Add project" action CreateProject }
|
|
110
|
-
button { label "Back to board" action OpenBoard }
|
|
81
|
+
query FileList(tour: ref(Tour)) -> list<FileGuide> {
|
|
82
|
+
from FileGuide
|
|
111
83
|
}
|
|
112
84
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
create(Workspace, { id: "w1", view: "board" }),
|
|
116
|
-
create(Project, { id: "p1", name: "General" })
|
|
117
|
-
]
|
|
118
|
-
when: CreateTask on Task { id: "t1", project: "p1", title: "Ship the compiler", status: "todo", assignee: "" }
|
|
119
|
-
then: [
|
|
120
|
-
entity_count(Task) == 1,
|
|
121
|
-
entity_has(Task.1, title == "Ship the compiler")
|
|
122
|
-
]
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
test TitleRequiredRejectsEmpty {
|
|
126
|
-
setup: [
|
|
127
|
-
create(Workspace, { id: "w1", view: "board" }),
|
|
128
|
-
create(Project, { id: "p1", name: "General" })
|
|
129
|
-
]
|
|
130
|
-
when: CreateTask on Task { id: "t1", project: "p1", title: "", status: "todo", assignee: "" }
|
|
131
|
-
then: [
|
|
132
|
-
error_raised('title is required'),
|
|
133
|
-
entity_count(Task) == 0
|
|
134
|
-
]
|
|
85
|
+
query StepList(tour: ref(Tour)) -> list<BuildStep> {
|
|
86
|
+
from BuildStep
|
|
135
87
|
}
|
|
136
88
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
create(Workspace, { id: "w1", view: "board" }),
|
|
140
|
-
create(Project, { id: "p1", name: "General" })
|
|
141
|
-
]
|
|
142
|
-
when: CreateTask on Task { id: "t1", project: "p1", title: "Ship the compiler", status: "doing", assignee: "" }
|
|
143
|
-
then: [
|
|
144
|
-
error_raised('assignee is required when a task is doing'),
|
|
145
|
-
entity_count(Task) == 0
|
|
146
|
-
]
|
|
89
|
+
query ResourceList(tour: ref(Tour)) -> list<Resource> {
|
|
90
|
+
from Resource
|
|
147
91
|
}
|
|
148
92
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
93
|
+
// --- Screens: declarative flow + copy (all text lives here) -----------------
|
|
94
|
+
screen Welcome {
|
|
95
|
+
when tour.page == 'welcome'
|
|
96
|
+
title "Welcome to MinLang"
|
|
97
|
+
body "The whole app is one .ml file. Edit it, run make compile, and this UI updates."
|
|
98
|
+
hint "Try the demo: add a note below, then take the tour."
|
|
99
|
+
board NoteList
|
|
100
|
+
primary { label "Say hello" action AddNote }
|
|
101
|
+
button { label "What's in this project" action ShowFiles }
|
|
102
|
+
button { label "Learn more" action ShowResources }
|
|
158
103
|
}
|
|
159
104
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
then: [
|
|
168
|
-
entity_has(Task.1, status == "doing"),
|
|
169
|
-
entity_count(Task) == 1
|
|
170
|
-
]
|
|
105
|
+
screen Files {
|
|
106
|
+
when tour.page == 'files'
|
|
107
|
+
title "What's in this project"
|
|
108
|
+
body "Everything is generated from your .ml source. Edit it, never app/generated."
|
|
109
|
+
board FileList
|
|
110
|
+
primary { label "How to enhance it" action ShowEnhance }
|
|
111
|
+
button { label "Back to start" action ShowWelcome }
|
|
171
112
|
}
|
|
172
113
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
then: [
|
|
181
|
-
error_raised('assignee is required when a task is doing'),
|
|
182
|
-
entity_has(Task.1, status == "todo")
|
|
183
|
-
]
|
|
114
|
+
screen Enhance {
|
|
115
|
+
when tour.page == 'enhance'
|
|
116
|
+
title "Make it yours"
|
|
117
|
+
body "Describe your own domain in the .ml, then compile and test."
|
|
118
|
+
board StepList
|
|
119
|
+
primary { label "Resources" action ShowResources }
|
|
120
|
+
button { label "Back to start" action ShowWelcome }
|
|
184
121
|
}
|
|
185
122
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
entity_has(Task.1, title == "New name")
|
|
195
|
-
]
|
|
123
|
+
screen Resources {
|
|
124
|
+
when tour.page == 'resources'
|
|
125
|
+
title "Learn more"
|
|
126
|
+
body "The language bundle has the full rules and the web authoring guide."
|
|
127
|
+
hint "Fetch it: github.com/codeshift-ai-solutions/minlang-releases/releases/latest/download/minlang-language-bundle.md"
|
|
128
|
+
board ResourceList
|
|
129
|
+
primary { label "Back to start" action ShowWelcome }
|
|
130
|
+
button { label "What's in this project" action ShowFiles }
|
|
196
131
|
}
|
|
197
132
|
|
|
198
|
-
|
|
133
|
+
// --- Tests: the success / failure / no-change triad -------------------------
|
|
134
|
+
test AddNoteSucceeds {
|
|
199
135
|
setup: [
|
|
200
|
-
create(
|
|
136
|
+
create(Tour, { id: "t1", page: "welcome" })
|
|
201
137
|
]
|
|
202
|
-
when:
|
|
138
|
+
when: AddNote on Note { id: "n1", greeting: "Hello, MinLang" }
|
|
203
139
|
then: [
|
|
204
|
-
entity_count(
|
|
205
|
-
entity_has(
|
|
140
|
+
entity_count(Note) == 1,
|
|
141
|
+
entity_has(Note.1, greeting == "Hello, MinLang")
|
|
206
142
|
]
|
|
207
143
|
}
|
|
208
144
|
|
|
209
|
-
test
|
|
145
|
+
test GreetingRequiredRejectsEmpty {
|
|
210
146
|
setup: [
|
|
211
|
-
create(
|
|
147
|
+
create(Tour, { id: "t1", page: "welcome" })
|
|
212
148
|
]
|
|
213
|
-
when:
|
|
149
|
+
when: AddNote on Note { id: "n1", greeting: "" }
|
|
214
150
|
then: [
|
|
215
|
-
error_raised('
|
|
216
|
-
entity_count(
|
|
151
|
+
error_raised('a greeting is required'),
|
|
152
|
+
entity_count(Note) == 0
|
|
217
153
|
]
|
|
218
154
|
}
|
|
219
155
|
|
|
220
|
-
test
|
|
156
|
+
test ShowFilesSwitchesPage {
|
|
221
157
|
setup: [
|
|
222
|
-
create(
|
|
223
|
-
create(Project, { id: "p0", name: "General" })
|
|
158
|
+
create(Tour, { id: "t1", page: "welcome" })
|
|
224
159
|
]
|
|
225
|
-
when:
|
|
160
|
+
when: ShowFiles on Tour { tour: "t1" }
|
|
226
161
|
then: [
|
|
227
|
-
|
|
228
|
-
entity_count(Project) == 1
|
|
162
|
+
entity_has(Tour.1, page == "files")
|
|
229
163
|
]
|
|
230
164
|
}
|
|
231
165
|
|
|
232
|
-
test
|
|
166
|
+
test ShowWelcomeReturnsHome {
|
|
233
167
|
setup: [
|
|
234
|
-
create(
|
|
168
|
+
create(Tour, { id: "t1", page: "files" })
|
|
235
169
|
]
|
|
236
|
-
when:
|
|
170
|
+
when: ShowWelcome on Tour { tour: "t1" }
|
|
237
171
|
then: [
|
|
238
|
-
entity_has(
|
|
172
|
+
entity_has(Tour.1, page == "welcome")
|
|
239
173
|
]
|
|
240
174
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Core journeys: land on the welcome screen, add a greeting through the demo
|
|
2
|
+
// form, see the rejection path, and confirm the tour screens render their
|
|
3
|
+
// seeded tables. Deterministic: every run starts a fresh server with the
|
|
4
|
+
// same seed.
|
|
5
|
+
|
|
6
|
+
import { expect, test } from "@playwright/test";
|
|
7
|
+
|
|
8
|
+
test.describe.configure({ mode: "serial" });
|
|
9
|
+
|
|
10
|
+
test("welcome screen: add a greeting -> table row", async ({ page }) => {
|
|
11
|
+
await page.goto("/");
|
|
12
|
+
await expect(page.getByRole("heading", { name: "Welcome to MinLang" })).toBeVisible();
|
|
13
|
+
|
|
14
|
+
const form = page.getByRole("form", { name: "Add note" });
|
|
15
|
+
await form.getByLabel("Greeting").fill("Hi there");
|
|
16
|
+
await form.getByRole("button", { name: "Say hello" }).click();
|
|
17
|
+
|
|
18
|
+
await expect(page.getByRole("row").filter({ hasText: "Hi there" })).toBeVisible();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("validation rejection shows the exact constraint message", async ({ page }) => {
|
|
22
|
+
await page.goto("/");
|
|
23
|
+
const form = page.getByRole("form", { name: "Add note" });
|
|
24
|
+
await form.getByRole("button", { name: "Say hello" }).click();
|
|
25
|
+
await expect(form.getByRole("alert")).toHaveText("a greeting is required");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("tour screens render their seeded content tables", async ({ page }) => {
|
|
29
|
+
await page.goto("/screens/files");
|
|
30
|
+
await expect(page.getByRole("heading", { name: "What's in this project" })).toBeVisible();
|
|
31
|
+
await expect(page.getByRole("row").filter({ hasText: "app/generated/" })).toBeVisible();
|
|
32
|
+
|
|
33
|
+
await page.goto("/screens/resources");
|
|
34
|
+
await expect(page.getByRole("heading", { name: "Learn more" })).toBeVisible();
|
|
35
|
+
await expect(page.getByRole("row").filter({ hasText: "Language rules" })).toBeVisible();
|
|
36
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// WCAG 2.1 AA gate: axe-core scans per screen (no serious/critical
|
|
2
|
-
// violations) and a keyboard-only
|
|
2
|
+
// violations) and a keyboard-only "add a greeting" journey.
|
|
3
3
|
|
|
4
4
|
import AxeBuilder from "@axe-core/playwright";
|
|
5
5
|
import { expect, test } from "@playwright/test";
|
|
@@ -14,31 +14,27 @@ const scan = async (page: import("@playwright/test").Page): Promise<void> => {
|
|
|
14
14
|
expect(serious, JSON.stringify(serious, null, 2)).toEqual([]);
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
test("
|
|
17
|
+
test("welcome screen is axe-clean (WCAG 2.1 AA)", async ({ page }) => {
|
|
18
18
|
await page.goto("/");
|
|
19
|
-
await expect(page.getByRole("heading", { name: "
|
|
19
|
+
await expect(page.getByRole("heading", { name: "Welcome to MinLang" })).toBeVisible();
|
|
20
20
|
await scan(page);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
test("
|
|
24
|
-
await page.goto("/screens/
|
|
25
|
-
await expect(page.getByRole("heading", { name: "
|
|
23
|
+
test("files screen is axe-clean (WCAG 2.1 AA)", async ({ page }) => {
|
|
24
|
+
await page.goto("/screens/files");
|
|
25
|
+
await expect(page.getByRole("heading", { name: "What's in this project" })).toBeVisible();
|
|
26
26
|
await scan(page);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
test("keyboard-only journey:
|
|
29
|
+
test("keyboard-only journey: add a greeting without the mouse", async ({ page }) => {
|
|
30
30
|
await page.goto("/");
|
|
31
|
-
const form = page.getByRole("form", { name: "
|
|
32
|
-
await form.getByLabel("
|
|
33
|
-
await page.keyboard.
|
|
34
|
-
await page.keyboard.type("Keyboard task");
|
|
35
|
-
await page.keyboard.press("Tab"); // -> Status (select)
|
|
36
|
-
await page.keyboard.press("Tab"); // -> Assignee
|
|
37
|
-
await page.keyboard.type("sam");
|
|
31
|
+
const form = page.getByRole("form", { name: "Add note" });
|
|
32
|
+
await form.getByLabel("Greeting").focus();
|
|
33
|
+
await page.keyboard.type("Keyboard hello");
|
|
38
34
|
await page.keyboard.press("Tab"); // -> submit
|
|
39
|
-
await expect(form.getByRole("button", { name: "
|
|
35
|
+
await expect(form.getByRole("button", { name: "Say hello" })).toBeFocused();
|
|
40
36
|
await page.keyboard.press("Enter");
|
|
41
37
|
await expect(
|
|
42
|
-
page.getByRole("row").filter({ hasText: "Keyboard
|
|
38
|
+
page.getByRole("row").filter({ hasText: "Keyboard hello" }),
|
|
43
39
|
).toBeVisible();
|
|
44
40
|
});
|
package/template/app/seed.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// App wiring: bind the data adapter (in-memory default) with the
|
|
2
|
-
//
|
|
1
|
+
// App wiring: bind the data adapter (in-memory default) with the welcome
|
|
2
|
+
// app's starting content. Imported for its side effect by app/layout.tsx so
|
|
3
3
|
// it runs once in the route-server bundle before any render or action.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// The tables on the Files / Enhance / Resources screens are seeded here; the
|
|
5
|
+
// notes list starts empty so the first thing you do is add one.
|
|
6
6
|
|
|
7
7
|
import { createInMemoryAdapter } from "@minlang/runtime-web";
|
|
8
8
|
import { bindServerAdapter } from "@minlang/runtime-web/server";
|
|
@@ -11,7 +11,24 @@ import { emptyAppState } from "./generated/state";
|
|
|
11
11
|
bindServerAdapter(
|
|
12
12
|
createInMemoryAdapter({
|
|
13
13
|
...emptyAppState,
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
tours: [{ id: "t1", page: "welcome" }],
|
|
15
|
+
fileGuides: [
|
|
16
|
+
{ id: "f1", file: "*.ml", purpose: "The whole app: entities, rules, actions, screens, tests." },
|
|
17
|
+
{ id: "f2", file: "app/generated/", purpose: "Compiler output. Committed, never hand-edited." },
|
|
18
|
+
{ id: "f3", file: "app/", purpose: "Thin Next.js shell over the @minlang runtime." },
|
|
19
|
+
{ id: "f4", file: "minlang.json", purpose: "Pins the MinLang language + compiler versions." },
|
|
20
|
+
{ id: "f5", file: "AGENTS.md", purpose: "Instructions for AI coding agents working here." },
|
|
21
|
+
],
|
|
22
|
+
buildSteps: [
|
|
23
|
+
{ id: "s1", step: "Edit welcome.ml", detail: "Model your domain: entities, constraints, actions, screens." },
|
|
24
|
+
{ id: "s2", step: "make compile", detail: "Regenerate app/generated from the .ml source." },
|
|
25
|
+
{ id: "s3", step: "make test", detail: "Run the generated success / failure / no-change triads." },
|
|
26
|
+
{ id: "s4", step: "make dev", detail: "Open http://localhost:3111 and use your app." },
|
|
27
|
+
],
|
|
28
|
+
resources: [
|
|
29
|
+
{ id: "r1", topic: "Language rules", link: "github.com/codeshift-ai-solutions/minlang-releases/releases/latest/download/minlang-language-bundle.md" },
|
|
30
|
+
{ id: "r2", topic: "Run it", link: "make dev, then http://localhost:3111" },
|
|
31
|
+
{ id: "r3", topic: "Enhance it", link: "Edit welcome.ml, then make compile && make test" },
|
|
32
|
+
],
|
|
16
33
|
}),
|
|
17
34
|
);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// BDD: the welcome demo's domain flows through dispatch + the in-memory
|
|
2
|
+
// adapter — add a greeting, see the rejection path keep state unchanged,
|
|
3
|
+
// navigate between screens, and replay a fixed trace deterministically.
|
|
4
|
+
|
|
5
|
+
import { createInMemoryAdapter, dispatch } from "@minlang/runtime-web";
|
|
6
|
+
import type { DataAdapter, DispatchDeps } from "@minlang/runtime-web";
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import { applyAddNote, applyShowFiles } from "../generated/actions";
|
|
9
|
+
import type { AddNoteInput } from "../generated/actions";
|
|
10
|
+
import { runNoteList } from "../generated/queries";
|
|
11
|
+
import { emptyAppState } from "../generated/state";
|
|
12
|
+
import type { AppState } from "../generated/state";
|
|
13
|
+
|
|
14
|
+
const seed: AppState = {
|
|
15
|
+
...emptyAppState,
|
|
16
|
+
tours: [{ id: "t1", page: "welcome" }],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const fixedDeps = (adapter: DataAdapter<AppState>): DispatchDeps<AppState> => ({
|
|
20
|
+
adapter,
|
|
21
|
+
clock: { currentTime: () => "2026-01-01T00:00:00Z" },
|
|
22
|
+
rng: { draw: () => 7 },
|
|
23
|
+
identity: { currentActorId: () => "test-actor" },
|
|
24
|
+
logger: { info: () => undefined, warn: () => undefined, error: () => undefined },
|
|
25
|
+
correlationId: "c-test",
|
|
26
|
+
emptyState: emptyAppState,
|
|
27
|
+
newId: () => "id-fixed",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const noteInput = (overrides: Partial<AddNoteInput>): AddNoteInput => ({
|
|
31
|
+
id: "n1",
|
|
32
|
+
greeting: "Hello, MinLang",
|
|
33
|
+
actor_id: "test-actor",
|
|
34
|
+
...overrides,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("welcome demo flows", () => {
|
|
38
|
+
test("adding a greeting persists it to the notes list", async () => {
|
|
39
|
+
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
40
|
+
const deps = fixedDeps(adapter);
|
|
41
|
+
expect((await dispatch(applyAddNote, noteInput({}), deps)).ok).toBe(true);
|
|
42
|
+
const state = (await adapter.load()) ?? emptyAppState;
|
|
43
|
+
expect(runNoteList(state, { tour: "t1" }).map((n) => n.greeting)).toEqual(["Hello, MinLang"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("an empty greeting is rejected with the exact constraint message", async () => {
|
|
47
|
+
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
48
|
+
const outcome = await dispatch(applyAddNote, noteInput({ greeting: "" }), fixedDeps(adapter));
|
|
49
|
+
expect(outcome.ok).toBe(false);
|
|
50
|
+
if (!outcome.ok) {
|
|
51
|
+
expect(outcome.error.message).toBe("a greeting is required");
|
|
52
|
+
}
|
|
53
|
+
expect(((await adapter.load()) ?? emptyAppState).notes).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("navigation switches the active page", async () => {
|
|
57
|
+
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
58
|
+
const deps = fixedDeps(adapter);
|
|
59
|
+
expect((await dispatch(applyShowFiles, { tour: "t1", actor_id: "a" }, deps)).ok).toBe(true);
|
|
60
|
+
const state = (await adapter.load()) ?? emptyAppState;
|
|
61
|
+
expect(state.tours[0]?.page).toBe("files");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("replay: a fixed trace yields a deep-equal final state across runs", async () => {
|
|
65
|
+
const run = async (): Promise<AppState> => {
|
|
66
|
+
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
67
|
+
const deps = fixedDeps(adapter);
|
|
68
|
+
await dispatch(applyAddNote, noteInput({ id: "n1", greeting: "One" }), deps);
|
|
69
|
+
await dispatch(applyAddNote, noteInput({ id: "n2", greeting: "Two" }), deps);
|
|
70
|
+
await dispatch(applyShowFiles, { tour: "t1", actor_id: "test-actor" }, deps);
|
|
71
|
+
return (await adapter.load()) ?? emptyAppState;
|
|
72
|
+
};
|
|
73
|
+
const [first, second] = [await run(), await run()];
|
|
74
|
+
expect(first).toEqual(second);
|
|
75
|
+
expect(runNoteList(first, { tour: "t1" }).map((n) => n.greeting)).toEqual(["One", "Two"]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
// Core journeys: create a task, see the rejection path, move and rename it.
|
|
2
|
-
// Deterministic: every run starts a fresh server with the same seed.
|
|
3
|
-
|
|
4
|
-
import { expect, test } from "@playwright/test";
|
|
5
|
-
|
|
6
|
-
test.describe.configure({ mode: "serial" });
|
|
7
|
-
|
|
8
|
-
test("create task journey: form -> table row", async ({ page }) => {
|
|
9
|
-
await page.goto("/");
|
|
10
|
-
await expect(page.getByRole("heading", { name: "Task board" })).toBeVisible();
|
|
11
|
-
await expect(page.getByRole("note")).toContainText("No task yet");
|
|
12
|
-
|
|
13
|
-
const form = page.getByRole("form", { name: "Create task" });
|
|
14
|
-
await form.getByLabel("Title").fill("Ship the web target");
|
|
15
|
-
await form.getByLabel("Status").selectOption("todo");
|
|
16
|
-
await form.getByLabel("Assignee").fill("casey");
|
|
17
|
-
await form.getByRole("button", { name: "Add task" }).click();
|
|
18
|
-
|
|
19
|
-
const table = page.getByRole("table", { name: "Task board" });
|
|
20
|
-
await expect(table).toBeVisible();
|
|
21
|
-
await expect(table.getByRole("row").filter({ hasText: "Ship the web target" })).toBeVisible();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test("validation rejection shows the exact constraint message", async ({ page }) => {
|
|
25
|
-
await page.goto("/");
|
|
26
|
-
const form = page.getByRole("form", { name: "Create task" });
|
|
27
|
-
await form.getByLabel("Status").selectOption("doing");
|
|
28
|
-
await form.getByLabel("Title").fill("Doing without assignee");
|
|
29
|
-
await form.getByRole("button", { name: "Add task" }).click();
|
|
30
|
-
await expect(form.getByRole("alert")).toHaveText("assignee is required when a task is doing");
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("move task journey: status changes in the table", async ({ page }) => {
|
|
34
|
-
await page.goto("/");
|
|
35
|
-
const create = page.getByRole("form", { name: "Create task" });
|
|
36
|
-
await create.getByLabel("Title").fill("Move me");
|
|
37
|
-
await create.getByLabel("Assignee").fill("alex");
|
|
38
|
-
await create.getByRole("button", { name: "Add task" }).click();
|
|
39
|
-
await expect(page.getByRole("table", { name: "Task board" })).toBeVisible();
|
|
40
|
-
|
|
41
|
-
const move = page.getByRole("form", { name: "Move task" });
|
|
42
|
-
await move.getByLabel("Id").selectOption({ label: "Move me" });
|
|
43
|
-
await move.getByLabel("Status").selectOption("doing");
|
|
44
|
-
await move.getByRole("button", { name: "Move task" }).click();
|
|
45
|
-
const row = page.getByRole("row").filter({ hasText: "Move me" });
|
|
46
|
-
await expect(row).toContainText("doing");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("rename task journey + projects navigation", async ({ page }) => {
|
|
50
|
-
await page.goto("/");
|
|
51
|
-
const create = page.getByRole("form", { name: "Create task" });
|
|
52
|
-
await create.getByLabel("Title").fill("Old title");
|
|
53
|
-
await create.getByRole("button", { name: "Add task" }).click();
|
|
54
|
-
await expect(page.getByRole("table", { name: "Task board" })).toBeVisible();
|
|
55
|
-
|
|
56
|
-
const rename = page.getByRole("form", { name: "Rename task" });
|
|
57
|
-
await rename.getByLabel("Id").selectOption({ label: "Old title" });
|
|
58
|
-
await rename.getByLabel("Title").fill("New title");
|
|
59
|
-
await rename.getByRole("button", { name: "Rename task" }).click();
|
|
60
|
-
await expect(page.getByRole("row").filter({ hasText: "New title" })).toBeVisible();
|
|
61
|
-
|
|
62
|
-
await page.goto("/screens/projects");
|
|
63
|
-
await expect(page.getByRole("heading", { name: "Projects" })).toBeVisible();
|
|
64
|
-
const project = page.getByRole("form", { name: "Create project" });
|
|
65
|
-
await project.getByLabel("Name").fill("Platform");
|
|
66
|
-
await project.getByRole("button", { name: "Add project" }).click();
|
|
67
|
-
await expect(page.getByRole("row").filter({ hasText: "Platform" })).toBeVisible();
|
|
68
|
-
});
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
// BDD: end-to-end domain flows through dispatch + the in-memory adapter —
|
|
2
|
-
// create → move → rename journeys, rejection leaves state unchanged, and a
|
|
3
|
-
// deterministic replay over a fixed input trace.
|
|
4
|
-
|
|
5
|
-
import { createInMemoryAdapter, dispatch } from "@minlang/runtime-web";
|
|
6
|
-
import type { DataAdapter, DispatchDeps } from "@minlang/runtime-web";
|
|
7
|
-
import { describe, expect, test } from "vitest";
|
|
8
|
-
import { applyCreateTask, applyMoveTask, applyRenameTask } from "../generated/actions";
|
|
9
|
-
import type { CreateTaskInput } from "../generated/actions";
|
|
10
|
-
import { runTaskBoard } from "../generated/queries";
|
|
11
|
-
import { emptyAppState } from "../generated/state";
|
|
12
|
-
import type { AppState } from "../generated/state";
|
|
13
|
-
|
|
14
|
-
const seed: AppState = {
|
|
15
|
-
...emptyAppState,
|
|
16
|
-
workspaces: [{ id: "w1", view: "board" }],
|
|
17
|
-
projects: [{ id: "p1", name: "General" }],
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const fixedDeps = (adapter: DataAdapter<AppState>): DispatchDeps<AppState> => ({
|
|
21
|
-
adapter,
|
|
22
|
-
clock: { currentTime: () => "2026-01-01T00:00:00Z" },
|
|
23
|
-
rng: { draw: () => 7 },
|
|
24
|
-
identity: { currentActorId: () => "test-actor" },
|
|
25
|
-
logger: { info: () => undefined, warn: () => undefined, error: () => undefined },
|
|
26
|
-
correlationId: "c-test",
|
|
27
|
-
emptyState: emptyAppState,
|
|
28
|
-
newId: () => "id-fixed",
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
const taskInput = (overrides: Partial<CreateTaskInput>): CreateTaskInput => ({
|
|
32
|
-
id: "t1",
|
|
33
|
-
project: "p1",
|
|
34
|
-
title: "Ship the web target",
|
|
35
|
-
status: "todo",
|
|
36
|
-
assignee: "",
|
|
37
|
-
created_at: "2026-01-01T00:00:00Z",
|
|
38
|
-
actor_id: "test-actor",
|
|
39
|
-
...overrides,
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe("task-tracker domain flows", () => {
|
|
43
|
-
test("create → move → rename journey persists each step", async () => {
|
|
44
|
-
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
45
|
-
const deps = fixedDeps(adapter);
|
|
46
|
-
expect((await dispatch(applyCreateTask, taskInput({}), deps)).ok).toBe(true);
|
|
47
|
-
expect(
|
|
48
|
-
(await dispatch(applyMoveTask, { id: "t1", status: "doing", actor_id: "a" }, deps)).ok,
|
|
49
|
-
).toBe(false); // assignee required when doing
|
|
50
|
-
expect(
|
|
51
|
-
(
|
|
52
|
-
await dispatch(
|
|
53
|
-
applyRenameTask,
|
|
54
|
-
{ id: "t1", title: "Ship it", actor_id: "a" },
|
|
55
|
-
deps,
|
|
56
|
-
)
|
|
57
|
-
).ok,
|
|
58
|
-
).toBe(true);
|
|
59
|
-
const state = (await adapter.load()) ?? emptyAppState;
|
|
60
|
-
expect(runTaskBoard(state, { workspace: "w1" }).map((t) => t.title)).toEqual(["Ship it"]);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("rejection returns the exact constraint message and persists nothing", async () => {
|
|
64
|
-
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
65
|
-
const outcome = await dispatch(
|
|
66
|
-
applyCreateTask,
|
|
67
|
-
taskInput({ title: "" }),
|
|
68
|
-
fixedDeps(adapter),
|
|
69
|
-
);
|
|
70
|
-
expect(outcome.ok).toBe(false);
|
|
71
|
-
if (!outcome.ok) {
|
|
72
|
-
expect(outcome.error.message).toBe("title is required");
|
|
73
|
-
}
|
|
74
|
-
expect(((await adapter.load()) ?? emptyAppState).tasks).toHaveLength(0);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("unknown project is rejected by the count join", async () => {
|
|
78
|
-
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
79
|
-
const outcome = await dispatch(
|
|
80
|
-
applyCreateTask,
|
|
81
|
-
taskInput({ project: "ghost" }),
|
|
82
|
-
fixedDeps(adapter),
|
|
83
|
-
);
|
|
84
|
-
expect(outcome.ok).toBe(false);
|
|
85
|
-
if (!outcome.ok) {
|
|
86
|
-
expect(outcome.error.message).toBe("project does not exist");
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("replay: a fixed trace yields a deep-equal final state across runs", async () => {
|
|
91
|
-
const run = async (): Promise<AppState> => {
|
|
92
|
-
const adapter = createInMemoryAdapter<AppState>(seed);
|
|
93
|
-
const deps = fixedDeps(adapter);
|
|
94
|
-
await dispatch(applyCreateTask, taskInput({ id: "t1", title: "One" }), deps);
|
|
95
|
-
await dispatch(applyCreateTask, taskInput({ id: "t2", title: "Two" }), deps);
|
|
96
|
-
await dispatch(
|
|
97
|
-
applyMoveTask,
|
|
98
|
-
{ id: "t2", status: "done", actor_id: "test-actor" },
|
|
99
|
-
deps,
|
|
100
|
-
);
|
|
101
|
-
await dispatch(applyRenameTask, { id: "t1", title: "Won", actor_id: "test-actor" }, deps);
|
|
102
|
-
return (await adapter.load()) ?? emptyAppState;
|
|
103
|
-
};
|
|
104
|
-
const [first, second] = [await run(), await run()];
|
|
105
|
-
expect(first).toEqual(second);
|
|
106
|
-
expect(runTaskBoard(first, { workspace: "w1" }).map((t) => [t.title, t.status])).toEqual([
|
|
107
|
-
["Won", "todo"],
|
|
108
|
-
["Two", "done"],
|
|
109
|
-
]);
|
|
110
|
-
});
|
|
111
|
-
});
|