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 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 task tracker — replace it).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-minlang-app",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Scaffold a MinLang web app: write one .ml file, compile with ml1, deploy.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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` |
@@ -1,240 +1,174 @@
1
- entity Workspace {
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
- view: enum(board, projects)
13
+ page: enum(welcome, files, enhance, resources)
4
14
  }
5
15
 
6
- entity Project {
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
- name: string
21
+ greeting: string
9
22
  }
10
23
 
11
- entity Task {
24
+ // --- Seeded content shown as tables (rows live in app/seed.ts) --------------
25
+ entity FileGuide {
12
26
  id: string req
13
- project: ref(Project)
14
- title: string
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
- constraint TitleRequired {
22
- on: Task
23
- validate: NOT(self.title == '')
24
- message: 'title is required'
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
- action MoveTask(id: string, status: enum(todo, doing, done), actor_id: string) {
62
- on: Task
63
- set(status, status)
37
+ entity Resource {
38
+ id: string req
39
+ topic: string
40
+ link: string
64
41
  }
65
42
 
66
- action RenameTask(id: string, title: string, actor_id: string) {
67
- on: Task
68
- set(title, title)
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
- action OpenProjects(workspace: ref(Workspace), actor_id: string) {
72
- on: Workspace
73
- set(view, 'projects')
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 OpenBoard(workspace: ref(Workspace), actor_id: string) {
77
- on: Workspace
78
- set(view, 'board')
56
+ action ShowWelcome(tour: ref(Tour), actor_id: string) {
57
+ on: Tour
58
+ set(page, 'welcome')
79
59
  }
80
60
 
81
- query TaskBoard(workspace: ref(Workspace)) -> list<Task> {
82
- from Task
61
+ action ShowFiles(tour: ref(Tour), actor_id: string) {
62
+ on: Tour
63
+ set(page, 'files')
83
64
  }
84
65
 
85
- query ProjectList(workspace: ref(Workspace)) -> list<Project> {
86
- from Project
66
+ action ShowEnhance(tour: ref(Tour), actor_id: string) {
67
+ on: Tour
68
+ set(page, 'enhance')
87
69
  }
88
70
 
89
- query TaskDetail(task: ref(Task)) -> Task {
90
- from Task
71
+ action ShowResources(tour: ref(Tour), actor_id: string) {
72
+ on: Tour
73
+ set(page, 'resources')
91
74
  }
92
75
 
93
- screen Board {
94
- when workspace.view == 'board'
95
- title "Task board"
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
- screen Projects {
105
- when workspace.view == 'projects'
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
- test CreateTaskSucceeds {
114
- setup: [
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
- test AssigneeRequiredWhenDoingRejects {
138
- setup: [
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
- test ProjectExistsRejectsUnknown {
150
- setup: [
151
- create(Workspace, { id: "w1", view: "board" })
152
- ]
153
- when: CreateTask on Task { id: "t1", project: "ghost", title: "Ship the compiler", status: "todo", assignee: "" }
154
- then: [
155
- error_raised('project does not exist'),
156
- entity_count(Task) == 0
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
- test MoveTaskSucceeds {
161
- setup: [
162
- create(Workspace, { id: "w1", view: "board" }),
163
- create(Project, { id: "p1", name: "General" }),
164
- create(Task, { id: "t1", project: "p1", title: "Ship the compiler", status: "todo", assignee: "casey", created_at: "2026-01-01T00:00:00Z", actor_id: "a1" })
165
- ]
166
- when: MoveTask on Task { id: "t1", status: "doing" }
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
- test MoveTaskRejectsDoingWithoutAssignee {
174
- setup: [
175
- create(Workspace, { id: "w1", view: "board" }),
176
- create(Project, { id: "p1", name: "General" }),
177
- create(Task, { id: "t1", project: "p1", title: "Ship the compiler", status: "todo", assignee: "", created_at: "2026-01-01T00:00:00Z", actor_id: "a1" })
178
- ]
179
- when: MoveTask on Task { id: "t1", status: "doing" }
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
- test RenameTaskSucceeds {
187
- setup: [
188
- create(Workspace, { id: "w1", view: "board" }),
189
- create(Project, { id: "p1", name: "General" }),
190
- create(Task, { id: "t1", project: "p1", title: "Old name", status: "todo", assignee: "", created_at: "2026-01-01T00:00:00Z", actor_id: "a1" })
191
- ]
192
- when: RenameTask on Task { id: "t1", title: "New name" }
193
- then: [
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
- test CreateProjectSucceeds {
133
+ // --- Tests: the success / failure / no-change triad -------------------------
134
+ test AddNoteSucceeds {
199
135
  setup: [
200
- create(Workspace, { id: "w1", view: "board" })
136
+ create(Tour, { id: "t1", page: "welcome" })
201
137
  ]
202
- when: CreateProject on Project { id: "p1", name: "Platform" }
138
+ when: AddNote on Note { id: "n1", greeting: "Hello, MinLang" }
203
139
  then: [
204
- entity_count(Project) == 1,
205
- entity_has(Project.1, name == "Platform")
140
+ entity_count(Note) == 1,
141
+ entity_has(Note.1, greeting == "Hello, MinLang")
206
142
  ]
207
143
  }
208
144
 
209
- test ProjectNameRequiredRejectsEmpty {
145
+ test GreetingRequiredRejectsEmpty {
210
146
  setup: [
211
- create(Workspace, { id: "w1", view: "board" })
147
+ create(Tour, { id: "t1", page: "welcome" })
212
148
  ]
213
- when: CreateProject on Project { id: "p1", name: "" }
149
+ when: AddNote on Note { id: "n1", greeting: "" }
214
150
  then: [
215
- error_raised('project name is required'),
216
- entity_count(Project) == 0
151
+ error_raised('a greeting is required'),
152
+ entity_count(Note) == 0
217
153
  ]
218
154
  }
219
155
 
220
- test UniqueProjectNameRejectsDuplicate {
156
+ test ShowFilesSwitchesPage {
221
157
  setup: [
222
- create(Workspace, { id: "w1", view: "board" }),
223
- create(Project, { id: "p0", name: "General" })
158
+ create(Tour, { id: "t1", page: "welcome" })
224
159
  ]
225
- when: CreateProject on Project { id: "p1", name: "General" }
160
+ when: ShowFiles on Tour { tour: "t1" }
226
161
  then: [
227
- error_raised('a project with this name already exists'),
228
- entity_count(Project) == 1
162
+ entity_has(Tour.1, page == "files")
229
163
  ]
230
164
  }
231
165
 
232
- test OpenProjectsSwitchesView {
166
+ test ShowWelcomeReturnsHome {
233
167
  setup: [
234
- create(Workspace, { id: "w1", view: "board" })
168
+ create(Tour, { id: "t1", page: "files" })
235
169
  ]
236
- when: OpenProjects on Workspace { workspace: "w1" }
170
+ when: ShowWelcome on Tour { tour: "t1" }
237
171
  then: [
238
- entity_has(Workspace.1, view == "projects")
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 create journey.
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("board screen is axe-clean (WCAG 2.1 AA)", async ({ page }) => {
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: "Task board" })).toBeVisible();
19
+ await expect(page.getByRole("heading", { name: "Welcome to MinLang" })).toBeVisible();
20
20
  await scan(page);
21
21
  });
22
22
 
23
- test("projects screen is axe-clean (WCAG 2.1 AA)", async ({ page }) => {
24
- await page.goto("/screens/projects");
25
- await expect(page.getByRole("heading", { name: "Projects" })).toBeVisible();
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: create a task without the mouse", async ({ page }) => {
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: "Create task" });
32
- await form.getByLabel("Project").focus();
33
- await page.keyboard.press("Tab"); // -> Title
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: "Add task" })).toBeFocused();
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 task" }),
38
+ page.getByRole("row").filter({ hasText: "Keyboard hello" }),
43
39
  ).toBeVisible();
44
40
  });
@@ -1,8 +1,8 @@
1
- // App wiring: bind the data adapter (in-memory default) with the
2
- // deterministic demo seed. Imported for its side effect by app/layout.tsx so
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
- // WEB_DATA_ADAPTER=memory is the supported adapter for plan completion; a
5
- // real adapter (USE_REAL_SERVICES=true) would bind here instead.
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
- workspaces: [{ id: "w1", view: "board" }],
15
- projects: [{ id: "p1", name: "General" }],
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,4 @@
1
+ **/generated/** linguist-generated=true
2
+ **/generated/ui/wire/*.mlui linguist-generated=false linguist-detectable=false
3
+ minlang-core/ linguist-vendored=true
4
+ *.mlui text eol=lf
@@ -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
- });