create-minlang-app 0.3.0 → 0.4.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/bin.mjs +1 -1
- package/package.json +1 -1
- package/template/AGENTS.md +24 -1
- package/template/MINLANG_LANGUAGE_SUPPORT.md +74 -0
- package/template/README.md +6 -0
- 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/ui/design/screens.mlui +74 -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
|
@@ -12,6 +12,17 @@ To change behavior, edit `__APP_NAME__.ml`, then run `make compile`.
|
|
|
12
12
|
Never edit files under `app/generated/` — CI recompiles with `--check`
|
|
13
13
|
and fails the build if they drift from the `.ml` source.
|
|
14
14
|
|
|
15
|
+
For UI changes, sketch the intended screens in `ui/design/screens.mlui` first.
|
|
16
|
+
Then translate that sketch into `screen` blocks in `__APP_NAME__.ml`, compile,
|
|
17
|
+
and compare the generated `app/generated/ui/wire/*.mlui` projections. The
|
|
18
|
+
`ui/design` sketch is hand-authored; generated wireframes are not.
|
|
19
|
+
|
|
20
|
+
When product behavior cannot yet fit the language (remote services, uploads,
|
|
21
|
+
rich local UI, AI structured outputs, full app shell/test generation), read
|
|
22
|
+
`MINLANG_LANGUAGE_SUPPORT.md`. `make compile` refreshes
|
|
23
|
+
`app/generated/MINLANG_LANGUAGE_SUPPORT.md`; agents should write any audit
|
|
24
|
+
results to `MINLANG_LANGUAGE_SUPPORT_RESULTS.md`, not into generated output.
|
|
25
|
+
|
|
15
26
|
## Language authority (fetch before writing MinLang)
|
|
16
27
|
|
|
17
28
|
Download the single-file language bundle and read it before producing or
|
|
@@ -27,6 +38,17 @@ detector list, and the end-to-end web-app authoring guide. Highlights:
|
|
|
27
38
|
- Generate -> validate -> fix -> revalidate until zero violations, then
|
|
28
39
|
output. `ml1 validate` is the same gate CI runs.
|
|
29
40
|
|
|
41
|
+
## Language version & updates
|
|
42
|
+
|
|
43
|
+
This repo is pinned to a MinLang language version in `minlang.json`
|
|
44
|
+
(`languageBundleVersion`, written by `ml1 init`). Run `make update` (or
|
|
45
|
+
`ml1 update`) to pull a newer compiler + language. When the language has
|
|
46
|
+
moved on, update writes `MINLANG_MIGRATION.md` — a checklist of
|
|
47
|
+
old-form -> new-form recommendations. Read it, apply the changes to
|
|
48
|
+
`__APP_NAME__.ml`, run `make compile && make test` until green, then delete
|
|
49
|
+
it. `ml1 update` advances the pin for additive upgrades and never edits your
|
|
50
|
+
`.ml` itself.
|
|
51
|
+
|
|
30
52
|
## Commands
|
|
31
53
|
|
|
32
54
|
| Task | Command |
|
|
@@ -34,11 +56,12 @@ detector list, and the end-to-end web-app authoring guide. Highlights:
|
|
|
34
56
|
| One-time setup | `corepack enable && corepack prepare pnpm@10.33.0 --activate` |
|
|
35
57
|
| Validate the program | `make validate` |
|
|
36
58
|
| Compile to `app/generated/` | `make compile` |
|
|
59
|
+
| Refresh language-support prompt without compiling | `ml1 support __APP_NAME__.ml` |
|
|
37
60
|
| Install shell deps | `pnpm --dir app install` |
|
|
38
61
|
| Unit + generated test triads | `make test` |
|
|
39
62
|
| Lint custom skins (`app/skins/`) | `make lint-skins` (also runs in `make test`) |
|
|
40
63
|
| Dev server (http://localhost:3111) | `make dev` |
|
|
41
|
-
| Update toolchain + deps | `make update` |
|
|
64
|
+
| Update toolchain, language pin + deps | `make update` (writes MINLANG_MIGRATION.md if the language moved on) |
|
|
42
65
|
| Production build | `make build` |
|
|
43
66
|
| Browser tests (a11y, perf) | `make test-e2e` (installs the browser on first run) |
|
|
44
67
|
| Screen previews (`screen-previews/*.png`, all screens, mobile + desktop) | `make preview` |
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# MinLang language support brief for `__APP_NAME__`
|
|
2
|
+
|
|
3
|
+
This file is for coding agents and maintainers. It is not app code, and it is
|
|
4
|
+
not proof that the capabilities below exist in the current language. It explains
|
|
5
|
+
what MinLang must gain before this project can delete custom web shell, API,
|
|
6
|
+
upload, provider, and test wiring.
|
|
7
|
+
|
|
8
|
+
## Current status
|
|
9
|
+
|
|
10
|
+
- Current web support covers persisted entities, constraints, pure actions,
|
|
11
|
+
queries, declarative screens, static themes/assets, generated reducers,
|
|
12
|
+
server action boundaries, screen schemas, wireframes, and generated unit tests
|
|
13
|
+
from MinLang test triads.
|
|
14
|
+
- Remote provider calls, uploads, local client state, rich forms, structured AI
|
|
15
|
+
outputs, and full app-shell/test generation need a future bundle and compiler
|
|
16
|
+
work before app authors can rely on them.
|
|
17
|
+
|
|
18
|
+
## Roadmap checklist
|
|
19
|
+
|
|
20
|
+
### Declarative secrets, config, and services
|
|
21
|
+
|
|
22
|
+
MinLang needs first-class declarations for provider secrets, environment-backed
|
|
23
|
+
config, base URLs, auth headers, endpoint schemas, generated `.env.example`,
|
|
24
|
+
and safe missing-secret errors.
|
|
25
|
+
|
|
26
|
+
### Server-only remote effects and action pipelines
|
|
27
|
+
|
|
28
|
+
Provider calls, retries, downloads, fallback chains, and persistence-after-effect
|
|
29
|
+
need an explicit server-only boundary before they can be generated safely.
|
|
30
|
+
|
|
31
|
+
### File inputs, uploads, and generated image assets
|
|
32
|
+
|
|
33
|
+
Browser file inputs, multipart transport, server-side blobs, previews, MIME
|
|
34
|
+
allowlists, size limits, URL fields, hex colors, and image transparency metadata
|
|
35
|
+
need typed language support.
|
|
36
|
+
|
|
37
|
+
### AI prompt templates and structured outputs
|
|
38
|
+
|
|
39
|
+
Prompt construction, vision inputs, provider model selection, JSON schemas,
|
|
40
|
+
normalization, fallback chains, and traceable prompt persistence need language
|
|
41
|
+
and validator ownership.
|
|
42
|
+
|
|
43
|
+
### Rich screens, local state, forms, and events
|
|
44
|
+
|
|
45
|
+
Full product UI needs local screen state, form controls, event handlers,
|
|
46
|
+
conditional result panels, root-route bindings, and richer accessibility
|
|
47
|
+
metadata.
|
|
48
|
+
|
|
49
|
+
### Structured theme, layout, and component variants
|
|
50
|
+
|
|
51
|
+
Product-grade web styling needs validated stack/grid/card/panel/result-grid
|
|
52
|
+
primitives and tokenized variants for buttons, tabs, fields, previews, alerts,
|
|
53
|
+
and swatches.
|
|
54
|
+
|
|
55
|
+
### Complete app shell and generated test suites
|
|
56
|
+
|
|
57
|
+
The web target should eventually generate package/config/env examples, seed
|
|
58
|
+
adapter binding, unit and pipeline tests, UI flows, accessibility, keyboard,
|
|
59
|
+
performance, and E2E checks from MinLang declarations.
|
|
60
|
+
|
|
61
|
+
## Agent prompt
|
|
62
|
+
|
|
63
|
+
Read this file together with the current MinLang bundle and the app source.
|
|
64
|
+
Then write findings to `MINLANG_LANGUAGE_SUPPORT_RESULTS.md` with:
|
|
65
|
+
|
|
66
|
+
1. The product behavior still implemented outside MinLang.
|
|
67
|
+
2. The language/runtime/codegen feature that would absorb each behavior.
|
|
68
|
+
3. The validation rule or detector needed to keep it deterministic and safe.
|
|
69
|
+
4. The generated files that should disappear once the feature exists.
|
|
70
|
+
5. The tests required before deleting handwritten code.
|
|
71
|
+
|
|
72
|
+
Do not edit a published language bundle in place. New syntax or enforcement
|
|
73
|
+
rules require a new full versioned bundle, a migration ledger entry, parser and
|
|
74
|
+
validator support, codegen support, docs, and deterministic tests.
|
package/template/README.md
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
A MinLang web app. The whole application lives in `__APP_NAME__.ml`;
|
|
4
4
|
`app/generated/` is compiler output (committed, never edited); `app/` holds
|
|
5
5
|
the thin Next.js shell consuming the published `@minlang/*` runtime.
|
|
6
|
+
Sketch screen changes first in `ui/design/screens.mlui`, then encode them in
|
|
7
|
+
the `.ml` `screen` blocks and compare against `app/generated/ui/wire/*.mlui`
|
|
8
|
+
after compile.
|
|
9
|
+
Read `MINLANG_LANGUAGE_SUPPORT.md` when the app needs behavior the current
|
|
10
|
+
bundle cannot express yet; generated compiles also refresh a copy under
|
|
11
|
+
`app/generated/`.
|
|
6
12
|
|
|
7
13
|
```bash
|
|
8
14
|
corepack enable && corepack prepare pnpm@10.33.0 --activate
|
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
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Source ASCII UI design for examples/welcome/welcome.ml.
|
|
2
|
+
Author this first, then encode it as MinLang `screen` blocks and compare with
|
|
3
|
+
web/generated/ui/wire/*.mlui after `make compile-web EXAMPLE=welcome`.
|
|
4
|
+
|
|
5
|
+
── Welcome ───────────────────────────────────────────────────────────────────
|
|
6
|
+
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
7
|
+
│ Welcome to MinLang │
|
|
8
|
+
├──────────────────────────────────────────────────────────────────────────────┤
|
|
9
|
+
│ The whole app is one .ml file. Edit it, run make compile, and this UI │
|
|
10
|
+
│ updates. │
|
|
11
|
+
│ │
|
|
12
|
+
│ Try the demo: add a note below, then take the tour. │
|
|
13
|
+
│ │
|
|
14
|
+
│ Notes │
|
|
15
|
+
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
|
|
16
|
+
│ │ Greeting │ │
|
|
17
|
+
│ ├──────────────────────────────────────────────────────────────────────────┤ │
|
|
18
|
+
│ │ Hi from MinLang │ │
|
|
19
|
+
│ └──────────────────────────────────────────────────────────────────────────┘ │
|
|
20
|
+
│ │
|
|
21
|
+
│ [ Say hello ] │
|
|
22
|
+
│ [ What's in this project ] [ Learn more ] │
|
|
23
|
+
└──────────────────────────────────────────────────────────────────────────────┘
|
|
24
|
+
|
|
25
|
+
── Files ─────────────────────────────────────────────────────────────────────
|
|
26
|
+
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
27
|
+
│ What's in this project │
|
|
28
|
+
├──────────────────────────────────────────────────────────────────────────────┤
|
|
29
|
+
│ Everything is generated from your .ml source. Edit it, never app/generated. │
|
|
30
|
+
│ │
|
|
31
|
+
│ ┌───────────────────────┬──────────────────────────────────────────────────┐ │
|
|
32
|
+
│ │ File │ Purpose │ │
|
|
33
|
+
│ ├───────────────────────┼──────────────────────────────────────────────────┤ │
|
|
34
|
+
│ │ *.ml │ Entities, constraints, actions, screens, tests │ │
|
|
35
|
+
│ │ app/generated/ │ Compiler output; never hand-edited │ │
|
|
36
|
+
│ │ AGENTS.md │ Instructions for AI coding agents │ │
|
|
37
|
+
│ └───────────────────────┴──────────────────────────────────────────────────┘ │
|
|
38
|
+
│ │
|
|
39
|
+
│ [ How to enhance it ] [ Back to start ] │
|
|
40
|
+
└──────────────────────────────────────────────────────────────────────────────┘
|
|
41
|
+
|
|
42
|
+
── Enhance ───────────────────────────────────────────────────────────────────
|
|
43
|
+
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
44
|
+
│ Make it yours │
|
|
45
|
+
├──────────────────────────────────────────────────────────────────────────────┤
|
|
46
|
+
│ Describe your own domain in the .ml, then compile and test. │
|
|
47
|
+
│ │
|
|
48
|
+
│ ┌────────────────────┬─────────────────────────────────────────────────────┐ │
|
|
49
|
+
│ │ Step │ Detail │ │
|
|
50
|
+
│ ├────────────────────┼─────────────────────────────────────────────────────┤ │
|
|
51
|
+
│ │ Edit *.ml │ Model entities, constraints, actions, screens │ │
|
|
52
|
+
│ │ make compile │ Regenerate app/generated │ │
|
|
53
|
+
│ │ make test │ Run generated triads and integration tests │ │
|
|
54
|
+
│ └────────────────────┴─────────────────────────────────────────────────────┘ │
|
|
55
|
+
│ │
|
|
56
|
+
│ [ Resources ] [ Back to start ] │
|
|
57
|
+
└──────────────────────────────────────────────────────────────────────────────┘
|
|
58
|
+
|
|
59
|
+
── Resources ─────────────────────────────────────────────────────────────────
|
|
60
|
+
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
61
|
+
│ Learn more │
|
|
62
|
+
├──────────────────────────────────────────────────────────────────────────────┤
|
|
63
|
+
│ The language bundle has the full rules and the web authoring guide. │
|
|
64
|
+
│ │
|
|
65
|
+
│ ┌────────────────────┬─────────────────────────────────────────────────────┐ │
|
|
66
|
+
│ │ Topic │ Link │ │
|
|
67
|
+
│ ├────────────────────┼─────────────────────────────────────────────────────┤ │
|
|
68
|
+
│ │ Language rules │ releases/latest/download/minlang-language-bundle.md │ │
|
|
69
|
+
│ │ Run it │ make dev │ │
|
|
70
|
+
│ │ Enhance it │ edit *.ml, then make compile && make test │ │
|
|
71
|
+
│ └────────────────────┴─────────────────────────────────────────────────────┘ │
|
|
72
|
+
│ │
|
|
73
|
+
│ [ Back to start ] [ What's in this project ] │
|
|
74
|
+
└──────────────────────────────────────────────────────────────────────────────┘
|
|
@@ -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
|
-
});
|