@live-change/frontend-template 0.9.200 → 0.9.203
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/.claude/rules/live-change-backend-architecture.md +5 -5
- package/.claude/rules/live-change-backend-event-sourcing.md +2 -2
- package/.claude/rules/live-change-backend-models-and-relations.md +41 -2
- package/.claude/rules/live-change-frontend-e2e-lifecycle.md +42 -0
- package/.claude/rules/live-change-frontend-i18n-locales.md +25 -0
- package/.claude/rules/live-change-frontend-vue-primevue.md +100 -4
- package/.claude/rules/live-change-node-toolchain-fnm.md +39 -0
- package/.claude/skills/create-skills-and-rules/SKILL.md +23 -0
- package/.claude/skills/live-change-design-actions-views-triggers/SKILL.md +6 -0
- package/.claude/skills/live-change-frontend-command-forms/SKILL.md +6 -5
- package/.claude/skills/live-change-frontend-data-views/SKILL.md +2 -0
- package/.claude/skills/live-change-frontend-e2e-lifecycle/SKILL.md +82 -0
- package/.claude/skills/live-change-node-toolchain-fnm/SKILL.md +44 -0
- package/.cursor/rules/live-change-backend-actions-views-triggers.mdc +35 -0
- package/.cursor/rules/live-change-backend-models-and-relations.mdc +33 -0
- package/.cursor/rules/live-change-frontend-e2e-lifecycle.mdc +15 -0
- package/.cursor/rules/live-change-frontend-i18n-locales.mdc +26 -0
- package/.cursor/rules/live-change-frontend-vue-primevue.mdc +25 -0
- package/.cursor/rules/live-change-node-toolchain-fnm.mdc +40 -0
- package/.cursor/skills/create-skills-and-rules.md +23 -0
- package/.cursor/skills/live-change-design-actions-views-triggers.md +6 -0
- package/.cursor/skills/live-change-frontend-command-forms.md +6 -5
- package/.cursor/skills/live-change-frontend-data-views.md +2 -0
- package/.cursor/skills/live-change-frontend-e2e-lifecycle.md +82 -0
- package/.cursor/skills/live-change-frontend-range-list.md +24 -0
- package/.cursor/skills/live-change-node-toolchain-fnm.md +44 -0
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/README.md +18 -0
- package/e2e/client-session.test.ts +17 -0
- package/e2e/e2eSuite.ts +12 -0
- package/e2e/env.ts +130 -0
- package/e2e/homepage.test.ts +17 -0
- package/e2e/steps.ts +3 -0
- package/e2e/withBrowser.ts +18 -0
- package/opencode.json +4 -1
- package/package.json +55 -53
|
@@ -249,6 +249,39 @@ indexes: {
|
|
|
249
249
|
|
|
250
250
|
Poza serwisem indeks bywa widoczny pod nazwą z prefiksem serwisu, np. `myService_Model_byDeviceAndStatus`.
|
|
251
251
|
|
|
252
|
+
### Indeksy `function` dla pól wyliczanych
|
|
253
|
+
|
|
254
|
+
Gdy część klucza indeksu nie istnieje jako zwykłe pole modelu (np. `month` wyliczany z `date`), użyj indeksu `function` zamiast `property`.
|
|
255
|
+
|
|
256
|
+
Zasady:
|
|
257
|
+
|
|
258
|
+
- buduj stabilny klucz jako `JSON.stringify(part1):JSON.stringify(part2):... + '_' + id`
|
|
259
|
+
- zwracaj obiekty `{ id, to }`, gdzie `to` wskazuje źródłowy rekord
|
|
260
|
+
- preferuj `table.map(mapper).to(output)` zamiast ręcznego `.onChange(...output.change...)`
|
|
261
|
+
- `map()` automatycznie odfiltrowuje `null`, więc mapper może zwracać tylko docelowy obiekt
|
|
262
|
+
|
|
263
|
+
```js
|
|
264
|
+
indexes: {
|
|
265
|
+
byBankAccountAndMonthAndDate: {
|
|
266
|
+
function: async (input, output, { tableName }) => {
|
|
267
|
+
const table = await input.table(tableName)
|
|
268
|
+
const mapper = obj => ({
|
|
269
|
+
id: [
|
|
270
|
+
obj.bankAccount,
|
|
271
|
+
obj.date?.slice(0, 7),
|
|
272
|
+
obj.date
|
|
273
|
+
].map(v => JSON.stringify(v)).join(':') + '_' + obj.id,
|
|
274
|
+
to: obj.id
|
|
275
|
+
})
|
|
276
|
+
await table.map(mapper).to(output)
|
|
277
|
+
},
|
|
278
|
+
parameters: {
|
|
279
|
+
tableName: definition.name + '_BankTransaction'
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
252
285
|
## Access control na relacjach
|
|
253
286
|
|
|
254
287
|
- Zawsze ustawiaj `readAccessControl` i `writeAccessControl` na relacjach (`userItem`, `itemOf`, `propertyOf`), zamiast polegać na domyślnym zachowaniu.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Stable node:test E2E lifecycle with env, withBrowser and e2eSuite
|
|
3
|
+
globs: **/e2e/**/*.{js,ts}
|
|
4
|
+
alwaysApply: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Frontend E2E lifecycle
|
|
8
|
+
|
|
9
|
+
- Keep teardown out of shared `e2e/env.ts`.
|
|
10
|
+
- Never use `after(...process.exit(...))` in `env.ts`.
|
|
11
|
+
- Add `disposeTestEnv()` in `env.ts` and call it from `process.on('beforeExit', ...)`.
|
|
12
|
+
- Put `after(async () => { await disposeTestEnv(); process.exit(0) })` in `e2eSuite.ts`.
|
|
13
|
+
- Wrap each `e2e/*.test.ts` file in one `e2eSuite('<suite-name>', () => { ... })`.
|
|
14
|
+
- Keep startup failure `process.exit(1)` in `getTestEnv()` catch.
|
|
15
|
+
- Run test commands with `fnm exec -- ...`.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Keep all locale files under front/locales in sync when adding or changing i18n strings
|
|
3
|
+
globs: **/front/locales/**/*.{json,js,ts}, **/front/src/**/*.{vue,js,ts}
|
|
4
|
+
alwaysApply: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Frontend i18n – every file in `front/locales`
|
|
8
|
+
|
|
9
|
+
When you add or change **user-visible strings** (labels, messages, buttons, errors, page titles, etc.) that go through **vue-i18n** or the project’s locale files, you must update **every** locale resource in that app’s **`front/locales/`** directory — not only one language.
|
|
10
|
+
|
|
11
|
+
## Required workflow
|
|
12
|
+
|
|
13
|
+
1. **Identify the app root** for the frontend you are editing (the folder whose tree contains `front/src` and `front/locales`).
|
|
14
|
+
2. **List all locale files** in `front/locales/` for that app (`en.json`, `pl.json`, `en.js`, `pl.js`, `landing-en.json`, … — whatever exists).
|
|
15
|
+
3. **Add or update the same keys** in **each** of those files. Structure and nesting must stay consistent across languages (same key paths).
|
|
16
|
+
4. **Do not** add a new key to a single locale file and leave others missing — that breaks builds or shows raw keys at runtime.
|
|
17
|
+
|
|
18
|
+
## Translations you are unsure about
|
|
19
|
+
|
|
20
|
+
- Prefer a correct string in the primary locales (e.g. `en` + `pl`) when the product supports them.
|
|
21
|
+
- For other files, use a sensible placeholder, copy from English, or add a clearly marked temporary string — but **still add the key** everywhere so nothing is omitted.
|
|
22
|
+
|
|
23
|
+
## Scope
|
|
24
|
+
|
|
25
|
+
- Applies to any work under `front/src` that introduces or changes translated text, and to direct edits in `front/locales`.
|
|
26
|
+
- If a project uses multiple locale formats (`.json` and `.js`), update **all** files that participate in i18n for that app, not only one extension.
|
|
@@ -6,6 +6,10 @@ alwaysApply: false
|
|
|
6
6
|
|
|
7
7
|
# Frontend na live-change-stack – Vue 3 + PrimeVue + Tailwind
|
|
8
8
|
|
|
9
|
+
## i18n i `front/locales`
|
|
10
|
+
|
|
11
|
+
Za każdym razem gdy dodajesz lub zmieniasz teksty pod tłumaczenia, stosuj **`live-change-frontend-i18n-locales`**: te same klucze muszą trafić do **wszystkich** plików w **`front/locales/`** danej aplikacji (każdy język / wariant — nie tylko jeden plik).
|
|
12
|
+
|
|
9
13
|
## Stack
|
|
10
14
|
|
|
11
15
|
- Vue 3 + TypeScript
|
|
@@ -207,6 +211,27 @@ path.blog.articles({})
|
|
|
207
211
|
|
|
208
212
|
Dostęp: `article.authorProfile?.firstName`. Działa zarówno z `live()` jak i `RangeViewer`.
|
|
209
213
|
|
|
214
|
+
## Listy zakresowe z reaktywnymi filtrami
|
|
215
|
+
|
|
216
|
+
Jeśli `pathFunction` dla listy zakresowej zależy od reaktywnych filtrów (np. miesiąc/status/szukaj), preferuj `ReactiveRangeViewer`.
|
|
217
|
+
|
|
218
|
+
Zasady:
|
|
219
|
+
|
|
220
|
+
- nie polegaj na samej zmianie `pathFunction` w `RangeViewer`
|
|
221
|
+
- nie rozrzucaj workaroundów typu ręczne `:key` po stronach
|
|
222
|
+
- użyj `sourceKey` jako jawnego triggera przeładowania
|
|
223
|
+
- gdy UX wymaga stabilności układu, ustaw `preserveHeightOnReload`
|
|
224
|
+
|
|
225
|
+
```vue
|
|
226
|
+
<ReactiveRangeViewer
|
|
227
|
+
:pathFunction="transactionsPathRange"
|
|
228
|
+
:sourceKey="JSON.stringify({ accountId, month: filterByMonth ? month : null })"
|
|
229
|
+
:preserveHeightOnReload="true"
|
|
230
|
+
:canLoadTop="false"
|
|
231
|
+
canDropBottom
|
|
232
|
+
/>
|
|
233
|
+
```
|
|
234
|
+
|
|
210
235
|
## WorkingZone dla akcji asynchronicznych
|
|
211
236
|
|
|
212
237
|
`ViewRoot` opakowuje każdą stronę w `<WorkingZone>`. Używaj `inject('workingZone')` dla akcji przycisków poza formularzami:
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Run Node, npm, npx, tsx with fnm exec using project .node-version or .nvmrc
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Node.js toolchain – use `fnm exec`
|
|
7
|
+
|
|
8
|
+
Do **not** rely on whatever Node.js version the agent sandbox or shell happens to use. That often differs from the project and breaks the LiveChange stack, tests, and `describe`.
|
|
9
|
+
|
|
10
|
+
Use **fnm** so the version from the project dotfiles (`.node-version` or `.nvmrc`) is selected.
|
|
11
|
+
|
|
12
|
+
## Required pattern
|
|
13
|
+
|
|
14
|
+
Work in the directory that contains the relevant `.node-version` or `.nvmrc` (app or package root). Prefix **every** `node`, `npm`, `npx`, `corepack`, or `tsx` invocation with:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
fnm exec -- <command and arguments>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
fnm exec -- node server/start.js describe --service myService --output yaml
|
|
24
|
+
fnm exec -- npm test
|
|
25
|
+
fnm exec -- npm run build
|
|
26
|
+
fnm exec -- npx eslint .
|
|
27
|
+
fnm exec -- tsx scripts/example.ts
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If the dotfile lives only under a subfolder (e.g. `auto-firma/app/`), `cd` there first, then run `fnm exec -- ...`.
|
|
31
|
+
|
|
32
|
+
## When this applies
|
|
33
|
+
|
|
34
|
+
- Tests, CI scripts, linters, builds
|
|
35
|
+
- Framework CLI: `describe`, dev servers, migrations, anything touching `@live-change/*`
|
|
36
|
+
- Any `npm` / `node` / `npx` / `tsx` for this monorepo or its subprojects
|
|
37
|
+
|
|
38
|
+
## If `fnm` is unavailable
|
|
39
|
+
|
|
40
|
+
Tell the user to install fnm or align the environment; do not proceed assuming an arbitrary `node` on `PATH` is correct.
|
|
@@ -137,6 +137,22 @@ globs: **/front/src/**/*.{vue,js,ts}
|
|
|
137
137
|
| `description` | What this rule covers (used for matching) |
|
|
138
138
|
| `globs` | File patterns that trigger this rule (e.g. `**/*.js`, `**/services/**/*.js`) |
|
|
139
139
|
|
|
140
|
+
### Backend / LiveChange service rules — standard `globs`
|
|
141
|
+
|
|
142
|
+
Rules for **server-side** LiveChange code (models, actions, triggers, service layout) should use a **comma-separated** `globs` line so they still match when a project stores services under different folder names (many teams copy `.cursor/rules` into other repos):
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
globs: **/services/**/*.js, **/server/**/*.js, server/**/*.js
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
| Pattern | Covers |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `**/services/**/*.js` | Trees such as `live-change-stack/services/<service>/` |
|
|
151
|
+
| `**/server/**/*.js` | Any nested `server/` directory (e.g. `app/server/`, `packages/foo/server/`) |
|
|
152
|
+
| `server/**/*.js` | Backend rooted at top-level `server/` |
|
|
153
|
+
|
|
154
|
+
Frontend rules keep their own globs (e.g. `**/front/src/**/*.{vue,js,ts}`). Do not drop the `server` variants for backend-only rules — otherwise Cursor will miss files after a layout change.
|
|
155
|
+
|
|
140
156
|
### Body structure
|
|
141
157
|
|
|
142
158
|
```markdown
|
|
@@ -181,6 +197,7 @@ alwaysApply: false
|
|
|
181
197
|
- Do NOT quote glob patterns in frontmatter
|
|
182
198
|
- Keep rules short (target 25 lines, max 50 lines for best Cursor performance)
|
|
183
199
|
- The `.mdc` extension is required for Cursor
|
|
200
|
+
- For **backend** LiveChange rules, use the same **`globs`** standard as in Claude rules: `**/services/**/*.js, **/server/**/*.js, server/**/*.js`
|
|
184
201
|
|
|
185
202
|
## Step 5 – Register rules in OpenCode (`opencode.json`)
|
|
186
203
|
|
|
@@ -198,8 +215,12 @@ OpenCode reads `.claude/skills/<name>/SKILL.md` natively for skills (no extra st
|
|
|
198
215
|
|
|
199
216
|
When you **create a new rule**, add its path to the `instructions` array in `opencode.json`.
|
|
200
217
|
|
|
218
|
+
Project rule **`live-change-node-toolchain-fnm`** should stay **first** (or early) in `instructions` so agents always load the requirement to run `node`, `npm`, `npx`, and `tsx` via `fnm exec` using `.node-version` / `.nvmrc`.
|
|
219
|
+
|
|
201
220
|
When you **create a new skill**, no `opencode.json` change is needed — OpenCode discovers skills from `.claude/skills/<name>/SKILL.md` automatically.
|
|
202
221
|
|
|
222
|
+
When a skill or rule shows shell examples that invoke **`node`**, **`npm`**, **`npx`**, or **`tsx`**, use the **`fnm exec -- …`** form (see `.claude/rules/live-change-node-toolchain-fnm.md` and skill `live-change-node-toolchain-fnm`).
|
|
223
|
+
|
|
203
224
|
**Important:** OpenCode ignores the `globs` frontmatter from Claude Code rules. All instructions listed in `opencode.json` are always loaded.
|
|
204
225
|
|
|
205
226
|
## Step 6 – Mirror Cursor skills (`.cursor/skills/<name>.md`)
|
|
@@ -239,10 +260,12 @@ done
|
|
|
239
260
|
|
|
240
261
|
## Checklist
|
|
241
262
|
|
|
263
|
+
- [ ] Shell examples for Node/npm use `fnm exec --` per `live-change-node-toolchain-fnm`
|
|
242
264
|
- [ ] Directory created: `.claude/skills/<name>/SKILL.md`
|
|
243
265
|
- [ ] Frontmatter has both `name` (matching dir) and `description`
|
|
244
266
|
- [ ] `.cursor/skills/<name>.md` mirrored (flat file, same content)
|
|
245
267
|
- [ ] `.claude/rules/*.md` created (if rule)
|
|
246
268
|
- [ ] `.cursor/rules/*.mdc` created with `globs` + `alwaysApply` (if rule)
|
|
269
|
+
- [ ] Backend rules use standard `globs`: `**/services/**/*.js, **/server/**/*.js, server/**/*.js` (when the rule targets LiveChange server code)
|
|
247
270
|
- [ ] `opencode.json` `instructions` array updated (if new rule)
|
|
248
271
|
- [ ] Sub-projects updated (automation, auto-firma)
|
|
@@ -7,6 +7,12 @@ description: Design actions, views, triggers with indexes and batch processing p
|
|
|
7
7
|
|
|
8
8
|
Use this skill to design **actions, views, and triggers** in LiveChange services while making good use of indexes and avoiding full-table scans.
|
|
9
9
|
|
|
10
|
+
## Reads vs writes (CQRS-like)
|
|
11
|
+
|
|
12
|
+
**Frontend (Vue):** load data with `usePath` + `live` / `useFetch` on **views**. Do **not** use `api.command` or `useActions()` only to fetch, preview, or compute display-only values — add a `definition.view` on the server and read it on the client.
|
|
13
|
+
|
|
14
|
+
**Backend (services):** **`definition.view`** is the read surface (including computed or preview data). From triggers/actions use **`app.viewGet`** / **`app.serviceViewGet`** when you need the same view layer as the client, or direct model/index reads where appropriate. **Actions and triggers** change state via **`emit`** / **`trigger`** / **`triggerService`**, not via fake “read-only actions.”
|
|
15
|
+
|
|
10
16
|
## When to use
|
|
11
17
|
|
|
12
18
|
- You add or change actions on existing models.
|
|
@@ -19,15 +19,16 @@ Before using this skill, pick the right approach:
|
|
|
19
19
|
|---|---|
|
|
20
20
|
| `editorData` | **Editing model records** (create/update). Drafts, validation, `AutoField`. See `live-change-frontend-editor-form` skill. |
|
|
21
21
|
| `actionData` | **One-shot action forms** (not CRUD). Submit once → done. See `live-change-frontend-action-form` skill. |
|
|
22
|
-
| `api.command` | **
|
|
22
|
+
| `api.command` | **Mutations only** — single button or programmatic calls that **change persisted state** (no form fields). This skill, Step 1. Not for loading or previewing data (use views + `live` / `useFetch`; see `live-change-frontend-data-views`). |
|
|
23
23
|
| `<command-form>` | **Avoid.** Legacy. Only for trivial prototypes without drafts or `AutoField`. This skill, Step 2. |
|
|
24
24
|
|
|
25
25
|
**Decision flow:**
|
|
26
26
|
|
|
27
|
-
1.
|
|
28
|
-
2.
|
|
29
|
-
3. Is it a
|
|
30
|
-
4.
|
|
27
|
+
1. Do you only need to **read** data (including previews)? → **Use views** (`live` / `useFetch`), not this skill.
|
|
28
|
+
2. Does the user fill in form fields? → **No**: use `api.command` (this skill) **only if** the operation **mutates** state.
|
|
29
|
+
3. Is it editing a model record? → **Yes**: use `editorData`.
|
|
30
|
+
4. Is it a one-shot action? → **Yes**: use `actionData`.
|
|
31
|
+
5. Only use `<command-form>` for the simplest throwaway cases.
|
|
31
32
|
|
|
32
33
|
## Step 1 – Use `api.command` directly
|
|
33
34
|
|
|
@@ -14,6 +14,8 @@ Use this skill when you build **reactive data views** using `usePath`, `live`, `
|
|
|
14
14
|
- You need to load related objects alongside the main data.
|
|
15
15
|
- You need to restrict data loading for unauthenticated users.
|
|
16
16
|
|
|
17
|
+
**Do not use `api.command` / `useActions()` to load data.** Commands are for **mutations**. For every read (lists, details, previews, “next number”), use **views** with `live` or `useFetch` as in this skill.
|
|
18
|
+
|
|
17
19
|
## Step 1 – Basic data loading with computed paths
|
|
18
20
|
|
|
19
21
|
When paths depend on reactive values (route params, props), wrap them in `computed()`:
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: live-change-frontend-e2e-lifecycle
|
|
3
|
+
description: Set up stable node:test E2E lifecycle with env, withBrowser and e2eSuite
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill: live-change-frontend-e2e-lifecycle
|
|
7
|
+
|
|
8
|
+
Use this skill when creating or refactoring LiveChange frontend E2E tests.
|
|
9
|
+
|
|
10
|
+
## Goal
|
|
11
|
+
|
|
12
|
+
Avoid flaky teardown where only the first test file runs because shared env teardown calls `process.exit`.
|
|
13
|
+
|
|
14
|
+
## Step 1 - Build shared env helper
|
|
15
|
+
|
|
16
|
+
Create `e2e/env.ts` with:
|
|
17
|
+
|
|
18
|
+
- `getTestEnv()` that starts `TestServer` once and memoizes with `envPromise`
|
|
19
|
+
- `disposeTestEnv()` that resets memoized state and disposes server
|
|
20
|
+
- fallback cleanup:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
process.on('beforeExit', () => {
|
|
24
|
+
void disposeTestEnv()
|
|
25
|
+
})
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Keep startup failure `process.exit(1)` inside `getTestEnv()` catch.
|
|
29
|
+
|
|
30
|
+
Do not add `after(...process.exit(...))` to this file.
|
|
31
|
+
|
|
32
|
+
## Step 2 - Isolate browser per test
|
|
33
|
+
|
|
34
|
+
Create `e2e/withBrowser.ts` with Playwright setup/teardown in `try/finally`:
|
|
35
|
+
|
|
36
|
+
- call `getTestEnv()` once per test execution
|
|
37
|
+
- open browser/context/page
|
|
38
|
+
- close context and browser in `finally`
|
|
39
|
+
|
|
40
|
+
## Step 3 - Add suite-level teardown
|
|
41
|
+
|
|
42
|
+
Create `e2e/e2eSuite.ts`:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { after, describe } from 'node:test'
|
|
46
|
+
import { disposeTestEnv } from './env.js'
|
|
47
|
+
|
|
48
|
+
export function e2eSuite(name: string, define: () => void): void {
|
|
49
|
+
describe(name, () => {
|
|
50
|
+
after(async () => {
|
|
51
|
+
await disposeTestEnv()
|
|
52
|
+
process.exit(0)
|
|
53
|
+
})
|
|
54
|
+
define()
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Step 4 - Wrap tests
|
|
60
|
+
|
|
61
|
+
Each `e2e/*.test.ts` file should use one wrapper:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import test from 'node:test'
|
|
65
|
+
import { e2eSuite } from './e2eSuite.js'
|
|
66
|
+
|
|
67
|
+
e2eSuite('example-suite', () => {
|
|
68
|
+
test('renders page', async () => {
|
|
69
|
+
// test body
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Step 5 - Verify
|
|
75
|
+
|
|
76
|
+
Run from project root with `fnm exec`:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
fnm exec -- npm run e2e
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Confirm multiple test files execute and process exits only after full suite teardown.
|
|
@@ -127,3 +127,27 @@ Iterate in the template:
|
|
|
127
127
|
</div>
|
|
128
128
|
</template>
|
|
129
129
|
```
|
|
130
|
+
|
|
131
|
+
## Step 5 – Reactive filters and safe reloads
|
|
132
|
+
|
|
133
|
+
When your `pathFunction` depends on changing filters (month, status, company, search), prefer `ReactiveRangeViewer`.
|
|
134
|
+
|
|
135
|
+
Why:
|
|
136
|
+
|
|
137
|
+
- reactivity in `pathFunction` can be subtle and lead to stale bucket state
|
|
138
|
+
- ad-hoc `:key` resets spread fragile logic in pages
|
|
139
|
+
- `ReactiveRangeViewer` centralizes reload logic and can preserve list height while reloading
|
|
140
|
+
|
|
141
|
+
```vue
|
|
142
|
+
<ReactiveRangeViewer
|
|
143
|
+
:pathFunction="transactionsPathRange"
|
|
144
|
+
:sourceKey="JSON.stringify({ accountId, month: filterByMonth ? month : null })"
|
|
145
|
+
:preserveHeightOnReload="true"
|
|
146
|
+
:canLoadTop="false"
|
|
147
|
+
canDropBottom
|
|
148
|
+
>
|
|
149
|
+
<template #default="{ item }">
|
|
150
|
+
<BankTransactionListItem :transaction="item" />
|
|
151
|
+
</template>
|
|
152
|
+
</ReactiveRangeViewer>
|
|
153
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: live-change-node-toolchain-fnm
|
|
3
|
+
description: Run node, npm, npx, tsx and framework CLI with fnm exec so .node-version and .nvmrc are respected
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill: Node toolchain with fnm
|
|
7
|
+
|
|
8
|
+
Use this skill whenever you run **Node**, **npm**, **npx**, **tsx**, or **corepack** in this repo (tests, `describe`, dev servers, scripts). Agents must not use the default sandbox Node; use **fnm exec** so the version from dotfiles applies.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- Running `npm test`, `npm run …`, linters, builds
|
|
13
|
+
- `node server/start.js describe` or any framework entry
|
|
14
|
+
- `tsx` for TypeScript scripts
|
|
15
|
+
- Any subprocess that would invoke `node` or `npm` for a project with `.node-version` / `.nvmrc`
|
|
16
|
+
|
|
17
|
+
## Step 1 – Find the right directory
|
|
18
|
+
|
|
19
|
+
Locate the nearest project root that has `.node-version` or `.nvmrc` for the task (often the app folder, e.g. `auto-firma/app/`).
|
|
20
|
+
|
|
21
|
+
`cd` to that directory before running commands so fnm reads the correct file.
|
|
22
|
+
|
|
23
|
+
## Step 2 – Prefix with fnm exec
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
fnm exec -- node server/start.js describe --service myService --output yaml
|
|
27
|
+
fnm exec -- npm test
|
|
28
|
+
fnm exec -- npx vitest
|
|
29
|
+
fnm exec -- tsx ./tools/something.ts
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The part after `--` is the same command you would run manually with the correct Node active.
|
|
33
|
+
|
|
34
|
+
## Step 3 – Nested monorepo paths
|
|
35
|
+
|
|
36
|
+
If you are in the repo root but the dotfile is only under `some-app/`:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cd some-app && fnm exec -- npm test
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## If fnm is missing
|
|
43
|
+
|
|
44
|
+
Do not fall back to bare `node` / `npm` for framework work. Report that fnm is required (or document a one-off alternative the user approved).
|
package/.node-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
20.19.2
|
package/.nvmrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
20.19.2
|
package/README.md
CHANGED
|
@@ -69,3 +69,21 @@ Typowy flow tworzenia nowej aplikacji:
|
|
|
69
69
|
|
|
70
70
|
Dokładny „manual” tego flow znajdzie się w sekcji `frontend/01-getting-started.md` dokumentacji.
|
|
71
71
|
|
|
72
|
+
### E2E starter (node:test + Playwright)
|
|
73
|
+
|
|
74
|
+
Szablon zawiera przykładowy zestaw E2E w katalogu `e2e/`:
|
|
75
|
+
|
|
76
|
+
- `env.ts` - lazy start `TestServer` i `disposeTestEnv()`
|
|
77
|
+
- `withBrowser.ts` - lifecycle przeglądarki per test
|
|
78
|
+
- `e2eSuite.ts` - wrapper `describe` + `after` z finalnym teardown
|
|
79
|
+
- `homepage.test.ts`, `client-session.test.ts` - przykładowe testy
|
|
80
|
+
|
|
81
|
+
Uruchamianie:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
fnm exec -- npm run e2e
|
|
85
|
+
fnm exec -- npm run e2e:headed
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Ważne: nie rejestruj `after(...process.exit(...))` w `e2e/env.ts`. Finalny `process.exit(0)` powinien być tylko w `e2eSuite.ts`.
|
|
89
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
import { withBrowser } from './withBrowser.js'
|
|
4
|
+
import { e2eSuite } from './e2eSuite.js'
|
|
5
|
+
|
|
6
|
+
e2eSuite('client-session', () => {
|
|
7
|
+
test('frontend initializes api client session', async () => {
|
|
8
|
+
await withBrowser(async (page, env) => {
|
|
9
|
+
await page.goto(env.url + '/', { waitUntil: 'networkidle' })
|
|
10
|
+
const session = await page.evaluate(() => {
|
|
11
|
+
const root = window as unknown as { api?: { client?: { value?: { session?: string } } } }
|
|
12
|
+
return root.api?.client?.value?.session
|
|
13
|
+
})
|
|
14
|
+
assert.ok(typeof session === 'string' && session.length > 0, 'session id is present')
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
})
|
package/e2e/e2eSuite.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { after, describe } from 'node:test'
|
|
2
|
+
import { disposeTestEnv } from './env.js'
|
|
3
|
+
|
|
4
|
+
export function e2eSuite(name: string, define: () => void): void {
|
|
5
|
+
describe(name, () => {
|
|
6
|
+
after(async () => {
|
|
7
|
+
await disposeTestEnv()
|
|
8
|
+
process.exit(0)
|
|
9
|
+
})
|
|
10
|
+
define()
|
|
11
|
+
})
|
|
12
|
+
}
|
package/e2e/env.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import { fileURLToPath } from 'url'
|
|
3
|
+
import { TestServer } from '@live-change/server'
|
|
4
|
+
import appConfig from '../server/app.config.js'
|
|
5
|
+
import * as services from '../server/services.list.js'
|
|
6
|
+
|
|
7
|
+
const READY_TIMEOUT_MS = 60000
|
|
8
|
+
const READY_POLL_MS = 2000
|
|
9
|
+
|
|
10
|
+
async function waitForServerReady(url: string): Promise<void> {
|
|
11
|
+
const deadline = Date.now() + READY_TIMEOUT_MS
|
|
12
|
+
while (Date.now() < deadline) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch(url)
|
|
15
|
+
if (res.ok) return
|
|
16
|
+
} catch {
|
|
17
|
+
// not ready yet
|
|
18
|
+
}
|
|
19
|
+
await new Promise((r) => setTimeout(r, READY_POLL_MS))
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`Server at ${url} did not become ready within ${READY_TIMEOUT_MS}ms`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
25
|
+
const serverDir = path.join(__dirname, '..', 'server')
|
|
26
|
+
const frontDir = path.join(__dirname, '..', 'front')
|
|
27
|
+
|
|
28
|
+
for (const serviceConfig of appConfig.services) {
|
|
29
|
+
const name = (serviceConfig as { name: string }).name
|
|
30
|
+
;(serviceConfig as { module?: unknown }).module = (services as Record<string, unknown>)[name]
|
|
31
|
+
}
|
|
32
|
+
;(appConfig as { init?: (s: unknown) => Promise<void> }).init = (services as { init: (s: unknown) => Promise<void> }).init
|
|
33
|
+
|
|
34
|
+
export type TestEnv = {
|
|
35
|
+
server: InstanceType<typeof TestServer>
|
|
36
|
+
url: string
|
|
37
|
+
haveService: (name: string) => { name: string; models: Record<string, { get: (id: string) => Promise<unknown> }>; views: Record<string, unknown>; actions: Record<string, unknown>; triggers: Record<string, unknown> }
|
|
38
|
+
haveModel: (serviceName: string, modelName: string) => { get: (id: string) => Promise<unknown>; create: (data: unknown) => Promise<unknown>; update: (id: string, data: unknown) => Promise<unknown>; delete: (id: string) => Promise<unknown>; indexObjectGet: (index: string, key: unknown, opts?: unknown) => Promise<unknown>; indexRangeGet: (index: string, key: unknown) => Promise<unknown[]>; definition: { properties: Record<string, { preFilter: (v: unknown) => unknown }> } }
|
|
39
|
+
haveView: (serviceName: string, viewName: string) => unknown
|
|
40
|
+
haveAction: (serviceName: string, actionName: string) => unknown
|
|
41
|
+
haveTrigger: (serviceName: string, triggerName: string) => unknown
|
|
42
|
+
grabObject: (serviceName: string, modelName: string, id: string) => Promise<unknown>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type TestServerInstance = InstanceType<typeof TestServer>
|
|
46
|
+
|
|
47
|
+
let envPromise: Promise<TestEnv> | null = null
|
|
48
|
+
let testServer: TestServerInstance | null = null
|
|
49
|
+
|
|
50
|
+
export async function disposeTestEnv(): Promise<void> {
|
|
51
|
+
const s = testServer
|
|
52
|
+
testServer = null
|
|
53
|
+
envPromise = null
|
|
54
|
+
if (s) await s.dispose()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function haveService(server: TestServerInstance, name: string) {
|
|
58
|
+
const service = server.apiServer.services.services.find((s: { name: string }) => s.name === name)
|
|
59
|
+
if (!service) throw new Error('service ' + name + ' not found')
|
|
60
|
+
return service
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function haveModel(server: TestServerInstance, serviceName: string, modelName: string) {
|
|
64
|
+
const service = haveService(server, serviceName)
|
|
65
|
+
const model = service.models[modelName]
|
|
66
|
+
if (!model) throw new Error('model ' + modelName + ' not found')
|
|
67
|
+
return model
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function getTestEnv(): Promise<TestEnv> {
|
|
71
|
+
if (envPromise) return envPromise
|
|
72
|
+
envPromise = (async () => {
|
|
73
|
+
const server = new TestServer({
|
|
74
|
+
dev: true,
|
|
75
|
+
enableSessions: true,
|
|
76
|
+
port: 0,
|
|
77
|
+
ssrRoot: frontDir,
|
|
78
|
+
initScript: path.join(serverDir, 'init.js'),
|
|
79
|
+
services: appConfig
|
|
80
|
+
})
|
|
81
|
+
console.log('starting test server')
|
|
82
|
+
try {
|
|
83
|
+
await server.start()
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error((error as Error).stack)
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
console.log('Test server started at', server.url)
|
|
89
|
+
console.log('waiting for front to be ready...')
|
|
90
|
+
await waitForServerReady(server.url!)
|
|
91
|
+
console.log('front ready')
|
|
92
|
+
|
|
93
|
+
testServer = server
|
|
94
|
+
|
|
95
|
+
process.on('beforeExit', () => {
|
|
96
|
+
void disposeTestEnv()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const url = server.url!
|
|
100
|
+
return {
|
|
101
|
+
server,
|
|
102
|
+
url,
|
|
103
|
+
haveService: (name: string) => haveService(server, name),
|
|
104
|
+
haveModel: (serviceName: string, modelName: string) => haveModel(server, serviceName, modelName),
|
|
105
|
+
haveView: (serviceName: string, viewName: string) => {
|
|
106
|
+
const service = haveService(server, serviceName)
|
|
107
|
+
const view = service.views[viewName]
|
|
108
|
+
if (!view) throw new Error('view ' + viewName + ' not found')
|
|
109
|
+
return view
|
|
110
|
+
},
|
|
111
|
+
haveAction: (serviceName: string, actionName: string) => {
|
|
112
|
+
const service = haveService(server, serviceName)
|
|
113
|
+
const action = service.actions[actionName]
|
|
114
|
+
if (!action) throw new Error('action ' + actionName + ' not found')
|
|
115
|
+
return action
|
|
116
|
+
},
|
|
117
|
+
haveTrigger: (serviceName: string, triggerName: string) => {
|
|
118
|
+
const service = haveService(server, serviceName)
|
|
119
|
+
const trigger = service.triggers[triggerName]
|
|
120
|
+
if (!trigger) throw new Error('trigger ' + triggerName + ' not found')
|
|
121
|
+
return trigger
|
|
122
|
+
},
|
|
123
|
+
grabObject: async (serviceName: string, modelName: string, id: string) => {
|
|
124
|
+
const model = haveModel(server, serviceName, modelName)
|
|
125
|
+
return await model.get(id)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
})()
|
|
129
|
+
return envPromise
|
|
130
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
import { withBrowser } from './withBrowser.js'
|
|
4
|
+
import { e2eSuite } from './e2eSuite.js'
|
|
5
|
+
|
|
6
|
+
e2eSuite('homepage', () => {
|
|
7
|
+
test('homepage responds and renders html', async () => {
|
|
8
|
+
await withBrowser(async (page, env) => {
|
|
9
|
+
const response = await page.goto(env.url + '/', { waitUntil: 'networkidle' })
|
|
10
|
+
assert.ok(response, 'navigation returned response')
|
|
11
|
+
assert.ok(response!.ok(), 'homepage responds with success status')
|
|
12
|
+
|
|
13
|
+
const html = await page.content()
|
|
14
|
+
assert.ok(html.includes('<html'), 'page content contains html tag')
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
})
|
package/e2e/steps.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { chromium } from 'playwright'
|
|
2
|
+
import type { Page } from 'playwright'
|
|
3
|
+
import { getTestEnv, type TestEnv } from './env.js'
|
|
4
|
+
|
|
5
|
+
export async function withBrowser(
|
|
6
|
+
fn: (page: Page, env: TestEnv) => Promise<void>
|
|
7
|
+
): Promise<void> {
|
|
8
|
+
const env = await getTestEnv()
|
|
9
|
+
const browser = await chromium.launch({ headless: process.env.SHOW_BROWSER ? false : true })
|
|
10
|
+
const context = await browser.newContext()
|
|
11
|
+
const page = await context.newPage()
|
|
12
|
+
try {
|
|
13
|
+
await fn(page, env)
|
|
14
|
+
} finally {
|
|
15
|
+
await context.close()
|
|
16
|
+
await browser.close()
|
|
17
|
+
}
|
|
18
|
+
}
|