ucode-agent 1.41.0 → 1.42.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/README.md +1 -1
- package/package.json +3 -2
- package/skills/build-app/DIGEST.md +12 -0
- package/src/core/loop.js +79 -1
- package/src/tools/browser.js +28 -2
- package/src/ui/screen.js +1 -1
- package/src/ui/theme.js +86 -10
- package/templates/next-shadcn/TEMPLATE.md +25 -0
- package/templates/next-shadcn/package.json +4 -2
- package/templates/next-shadcn/src/lib/store.test.ts +15 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucode-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.42.0",
|
|
4
4
|
"description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA and Cohere models.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "ucode.js",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"start": "node ucode.js",
|
|
18
18
|
"test": "node test/run.js",
|
|
19
|
-
"prepublishOnly": "node scripts/no-bundled-key.js && node test/run.js"
|
|
19
|
+
"prepublishOnly": "node scripts/no-bundled-key.js && node test/run.js",
|
|
20
|
+
"hooks": "node scripts/install-hooks.js"
|
|
20
21
|
},
|
|
21
22
|
"repository": {
|
|
22
23
|
"type": "git",
|
|
@@ -108,6 +108,18 @@ first time; then the server route with the real integration; then the core
|
|
|
108
108
|
loop UI wired to it; then every state — empty, loading, success, error,
|
|
109
109
|
invalid input; then polish: motion, responsive, copy, title and metadata.
|
|
110
110
|
|
|
111
|
+
## 5b. Tests, where there is a runner
|
|
112
|
+
|
|
113
|
+
`next-shadcn` ships vitest and one passing test, so `npm test` works from
|
|
114
|
+
the first minute — add cases for the core loop as you build it, not after, and
|
|
115
|
+
ucode will run the ones that touch whatever you change. Assert behaviour: that
|
|
116
|
+
adding an item puts it in the list, that the total is right, that an empty
|
|
117
|
+
input is refused.
|
|
118
|
+
|
|
119
|
+
`plain-html` has no runner and installs nothing, by design. Its test is the
|
|
120
|
+
browser check: ucode opens the app, types into the first field, presses Enter
|
|
121
|
+
and clicks the button that submits. Make sure that path is the one that works.
|
|
122
|
+
|
|
111
123
|
## 6. Prove it works, then report
|
|
112
124
|
|
|
113
125
|
`npm run build` type-checks and lints — a build that fails is not done. Start
|
package/src/core/loop.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import os from 'node:os';
|
|
16
|
-
import { appendFileSync } from 'node:fs';
|
|
16
|
+
import { appendFileSync, existsSync } from 'node:fs';
|
|
17
17
|
import { readFile, access, mkdir } from 'node:fs/promises';
|
|
18
18
|
import { testRunnerFor, relatedCommand, summariseFailures } from './tests.js';
|
|
19
19
|
import { LogWatch } from './livelog.js';
|
|
@@ -1105,6 +1105,7 @@ export class Agent {
|
|
|
1105
1105
|
this.lookedThisTurn = false;
|
|
1106
1106
|
this.reads = new Map();
|
|
1107
1107
|
this.declines = 0;
|
|
1108
|
+
this.apps = [];
|
|
1108
1109
|
forgetReviews(); // a new request: its apps get a fresh design review
|
|
1109
1110
|
const images = await this.attachImages(input);
|
|
1110
1111
|
this.push(images.length
|
|
@@ -1347,6 +1348,7 @@ export class Agent {
|
|
|
1347
1348
|
if (streaming) this.ui.streamEnd({ asNarration: narrating, closing });
|
|
1348
1349
|
else if (reply.text && narrating && isLabel(reply.text)) this.ui.narrate(reply.text);
|
|
1349
1350
|
else if (reply.text) this.ui.assistant(reply.text, { closing });
|
|
1351
|
+
if (closing && reply.text) this.checkClaims(reply.text);
|
|
1350
1352
|
|
|
1351
1353
|
if (reply.toolCalls.length === 0) {
|
|
1352
1354
|
// The answer stopped at the provider's output cap rather than at the
|
|
@@ -1682,6 +1684,13 @@ export class Agent {
|
|
|
1682
1684
|
// a designer's review, and now the app actually driven — and it was the
|
|
1683
1685
|
// quietest line on screen, saying only that it had happened. Its verdict
|
|
1684
1686
|
// goes on the same line, the way a change carries its two numbers.
|
|
1687
|
+
// Which app folders this turn actually made, so a second one can be
|
|
1688
|
+
// refused before it is built and any leftovers can be counted at the end.
|
|
1689
|
+
if (call.name === 'create_app' && call.args?.folder) {
|
|
1690
|
+
const made = path.resolve(this.cwd, String(call.args.folder));
|
|
1691
|
+
if (!(this.apps ??= []).includes(made)) this.apps.push(made);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1685
1694
|
if (call.name === 'look_at_app') {
|
|
1686
1695
|
const found = /^(\d+) problem/.exec(out.summary ?? '');
|
|
1687
1696
|
this.ui.runStat?.(found ? `${found[1]} to fix` : 'clean');
|
|
@@ -1689,6 +1698,34 @@ export class Agent {
|
|
|
1689
1698
|
this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
|
|
1690
1699
|
}
|
|
1691
1700
|
|
|
1701
|
+
/**
|
|
1702
|
+
* Files the closing message points at, checked rather than taken on trust.
|
|
1703
|
+
*
|
|
1704
|
+
* A build told the user it had removed two folders it had not removed. The
|
|
1705
|
+
* same habit sends someone to open a file that was never written. Saying a
|
|
1706
|
+
* thing is done when it is not is the one failure that costs the reader
|
|
1707
|
+
* their time rather than the writer's, and it is cheap to check: a path the
|
|
1708
|
+
* reply names in backticks either exists or it does not.
|
|
1709
|
+
*
|
|
1710
|
+
* Only paths with a file extension, only inside the project, and only a note
|
|
1711
|
+
* on screen — the turn is over by now, so this is for the person reading it,
|
|
1712
|
+
* not another round with the model.
|
|
1713
|
+
*/
|
|
1714
|
+
checkClaims(text) {
|
|
1715
|
+
const named = [...String(text).matchAll(/`([\w./-]+\.[a-z]{1,5})`/gi)].map((m) => m[1]);
|
|
1716
|
+
const root = path.resolve(this.cwd);
|
|
1717
|
+
|
|
1718
|
+
const missing = [...new Set(named)].filter((f) => {
|
|
1719
|
+
const abs = path.resolve(root, f);
|
|
1720
|
+
return abs.startsWith(root) && !existsSync(abs);
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
if (missing.length) {
|
|
1724
|
+
const is = missing.length === 1 ? 'is' : 'are';
|
|
1725
|
+
this.ui.note(`mentions ${missing.join(', ')} — ${is} not on disk`);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1692
1729
|
/** Show a tool failure, hand it to the model, and say if it was bad arguments. */
|
|
1693
1730
|
reportFailure(call, err) {
|
|
1694
1731
|
if (!(err instanceof ToolFailure)) throw err;
|
|
@@ -1784,6 +1821,30 @@ export class Agent {
|
|
|
1784
1821
|
this.reads.set(key, seen + 1);
|
|
1785
1822
|
}
|
|
1786
1823
|
|
|
1824
|
+
// Starting a second app instead of fixing the first.
|
|
1825
|
+
//
|
|
1826
|
+
// A traced build hit a problem in todo/, abandoned it and made todo-fixed/
|
|
1827
|
+
// — three create_app calls, two folders, one broken, the app typed twice.
|
|
1828
|
+
// Starting over is never the cheap way out of a problem in a file, and it
|
|
1829
|
+
// leaves the user to work out which folder is the real one.
|
|
1830
|
+
if (call.name === 'create_app' && call.args?.folder && (this.apps ?? []).length) {
|
|
1831
|
+
const wanted = path.resolve(this.cwd, String(call.args.folder));
|
|
1832
|
+
const already = this.apps.filter((f) => f !== wanted);
|
|
1833
|
+
if (already.length && !this.apps.includes(wanted)) {
|
|
1834
|
+
const show = already.map((f) => path.basename(f)).join(', ');
|
|
1835
|
+
throw new ToolFailure({
|
|
1836
|
+
kind: 'already_building',
|
|
1837
|
+
attempted: `creating ${call.args.folder}`,
|
|
1838
|
+
failed: `You already made ${show} this turn, and it is still there.`,
|
|
1839
|
+
fix:
|
|
1840
|
+
`Fix ${show} instead of starting again — whatever is wrong with it is a smaller `
|
|
1841
|
+
+ 'job than writing the whole app a second time, and a half-finished folder left '
|
|
1842
|
+
+ `beside the real one is worse than either. If ${show} genuinely cannot be saved, `
|
|
1843
|
+
+ 'delete it first, then create this one.',
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1787
1848
|
if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
|
|
1788
1849
|
if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
|
|
1789
1850
|
if (call.name === 'delegate') return this.delegate(call.args?.tasks);
|
|
@@ -2082,6 +2143,23 @@ export class Agent {
|
|
|
2082
2143
|
const live = await this.liveErrors();
|
|
2083
2144
|
if (live) problems.push(live);
|
|
2084
2145
|
|
|
2146
|
+
// Two app folders, one of them abandoned.
|
|
2147
|
+
//
|
|
2148
|
+
// A build hit a problem in todo/, started todo-fixed/, then said in its
|
|
2149
|
+
// reply that it had removed the duplicate — and had not. Both were still
|
|
2150
|
+
// on disk for the user to sort out, and the claim that they were not is
|
|
2151
|
+
// the failure this whole checking pass exists to catch: saying a thing is
|
|
2152
|
+
// done when it is not. So it is checked rather than believed.
|
|
2153
|
+
const apps = this.apps ?? [];
|
|
2154
|
+
if (apps.length > 1) {
|
|
2155
|
+
const names = apps.map((f) => path.basename(f));
|
|
2156
|
+
problems.push(
|
|
2157
|
+
`There are ${apps.length} app folders here now: ${names.join(', ')}. Only one of them is `
|
|
2158
|
+
+ 'the app. Delete the ones you are not shipping — actually delete them, do not just say '
|
|
2159
|
+
+ 'you have — and make sure the one you keep is the one that works.',
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2085
2163
|
// Then look at it, in the same pass that type-checks — not when the model
|
|
2086
2164
|
// remembers to. A check that runs only when it is asked for reports
|
|
2087
2165
|
// nothing on exactly the builds that needed it, and this is the only one
|
package/src/tools/browser.js
CHANGED
|
@@ -269,7 +269,25 @@ async function useTheApp(page) {
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
if (!tried.length) return null;
|
|
272
|
-
return { tried, worked:
|
|
272
|
+
if (!moved(before, after)) return { tried, worked: false };
|
|
273
|
+
|
|
274
|
+
// It worked. Did any of it last?
|
|
275
|
+
//
|
|
276
|
+
// An app that writes to localStorage and never reads it back looks perfect
|
|
277
|
+
// for as long as you stay on the page, and loses everything the moment
|
|
278
|
+
// anyone refreshes. Only asked when the app actually stored something —
|
|
279
|
+
// otherwise no persistence was intended, and reporting its absence would be
|
|
280
|
+
// inventing a requirement nobody asked for.
|
|
281
|
+
if (after.stored > before.stored) {
|
|
282
|
+
try {
|
|
283
|
+
await page.reload({ waitUntil: 'load', timeout: 20_000 });
|
|
284
|
+
await page.waitForTimeout(400);
|
|
285
|
+
const reloaded = await snapshot();
|
|
286
|
+
if (reloaded.text <= before.text) return { tried, worked: true, lost: true };
|
|
287
|
+
} catch { /* a reload that will not happen is not evidence of anything */ }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { tried, worked: true };
|
|
273
291
|
}
|
|
274
292
|
|
|
275
293
|
/**
|
|
@@ -415,8 +433,16 @@ export async function lookAtApp({ url, paths = ['/'] }) {
|
|
|
415
433
|
' everything else on this page is decoration until it works.',
|
|
416
434
|
);
|
|
417
435
|
problems++;
|
|
436
|
+
} else if (used?.lost) {
|
|
437
|
+
lines.push(
|
|
438
|
+
`- It works until you refresh. I ${used.tried.join(', then ')}, the page`,
|
|
439
|
+
' responded, and it wrote to localStorage — but after a reload it was back to',
|
|
440
|
+
' empty. Something is being saved and never read back at start-up. Load the',
|
|
441
|
+
' stored state when the page boots, and check it survives a refresh.',
|
|
442
|
+
);
|
|
443
|
+
problems++;
|
|
418
444
|
} else if (used) {
|
|
419
|
-
lines.push(`- Core loop works: I ${used.tried.join(', then ')},
|
|
445
|
+
lines.push(`- Core loop works: I ${used.tried.join(', then ')}, the page responded, and it survived a reload.`);
|
|
420
446
|
}
|
|
421
447
|
}
|
|
422
448
|
if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
|
package/src/ui/screen.js
CHANGED
|
@@ -594,7 +594,7 @@ export class Screen {
|
|
|
594
594
|
narrate(text) {
|
|
595
595
|
const line = asLabel(text);
|
|
596
596
|
if (!line) return;
|
|
597
|
-
this.push(dim(
|
|
597
|
+
this.push(`${dim('⋮')} ${dim(clip(line, this.width() - 4))}`);
|
|
598
598
|
this.updateSpinner(line);
|
|
599
599
|
}
|
|
600
600
|
|
package/src/ui/theme.js
CHANGED
|
@@ -155,7 +155,68 @@ export const boxRow = (content, width, paint = blue) =>
|
|
|
155
155
|
|
|
156
156
|
/** The string with its colour codes stripped — what the terminal actually shows. */
|
|
157
157
|
export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
158
|
-
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* How many columns one character occupies.
|
|
161
|
+
*
|
|
162
|
+
* Not every character is one cell wide, and counting them as though they were
|
|
163
|
+
* is how a box tears: the right border of a row holding CJK or an emoji lands
|
|
164
|
+
* one or two columns early, and every frame after it looks broken. It cost us
|
|
165
|
+
* a crooked credit line in the header for months — and any app whose name the
|
|
166
|
+
* model writes in Japanese would have done the same to the transcript.
|
|
167
|
+
*
|
|
168
|
+
* Three widths. Combining marks and the variation selectors hang off the
|
|
169
|
+
* character before them and take no room of their own. The wide ranges — CJK,
|
|
170
|
+
* Hangul, kana, fullwidth forms, and the emoji planes — are drawn two cells
|
|
171
|
+
* wide by every terminal worth supporting. Everything else is one.
|
|
172
|
+
*
|
|
173
|
+
* Ranges rather than a dependency: this is the whole of what a terminal needs,
|
|
174
|
+
* and a table of every Unicode width would be a megabyte to get the last
|
|
175
|
+
* fraction of a percent right.
|
|
176
|
+
*/
|
|
177
|
+
export function charWidth(code) {
|
|
178
|
+
// Zero: combining marks, joiners, variation selectors.
|
|
179
|
+
if ((code >= 0x0300 && code <= 0x036f)
|
|
180
|
+
|| (code >= 0x200b && code <= 0x200f)
|
|
181
|
+
|| (code >= 0xfe00 && code <= 0xfe0f)
|
|
182
|
+
|| (code >= 0xe0100 && code <= 0xe01ef)
|
|
183
|
+
|| code === 0x200d) return 0;
|
|
184
|
+
|
|
185
|
+
// Two: the wide and fullwidth blocks, and the emoji planes.
|
|
186
|
+
if ((code >= 0x1100 && code <= 0x115f)
|
|
187
|
+
|| (code >= 0x2e80 && code <= 0x303e)
|
|
188
|
+
|| (code >= 0x3041 && code <= 0x33ff)
|
|
189
|
+
|| (code >= 0x3400 && code <= 0x4dbf)
|
|
190
|
+
|| (code >= 0x4e00 && code <= 0x9fff)
|
|
191
|
+
|| (code >= 0xa000 && code <= 0xa4cf)
|
|
192
|
+
|| (code >= 0xac00 && code <= 0xd7a3)
|
|
193
|
+
|| (code >= 0xf900 && code <= 0xfaff)
|
|
194
|
+
|| (code >= 0xfe30 && code <= 0xfe6f)
|
|
195
|
+
|| (code >= 0xff00 && code <= 0xff60)
|
|
196
|
+
|| (code >= 0xffe0 && code <= 0xffe6)
|
|
197
|
+
|| (code >= 0x1f300 && code <= 0x1f64f)
|
|
198
|
+
|| (code >= 0x1f680 && code <= 0x1f6ff)
|
|
199
|
+
|| (code >= 0x1f900 && code <= 0x1f9ff)
|
|
200
|
+
|| (code >= 0x20000 && code <= 0x3fffd)) return 2;
|
|
201
|
+
|
|
202
|
+
return 1;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The columns a string takes up once its colour codes are discounted. */
|
|
206
|
+
export function visLen(s) {
|
|
207
|
+
const text = bare(s);
|
|
208
|
+
let cells = 0;
|
|
209
|
+
for (let i = 0; i < text.length;) {
|
|
210
|
+
const cp = text.codePointAt(i);
|
|
211
|
+
const ch = String.fromCodePoint(cp);
|
|
212
|
+
i += ch.length;
|
|
213
|
+
// A variation selector turns the character before it into an emoji, and
|
|
214
|
+
// an emoji is two cells wide however narrow its text form was.
|
|
215
|
+
if (text.codePointAt(i) === 0xfe0f) { cells += 2; i += 1; continue; }
|
|
216
|
+
cells += charWidth(cp);
|
|
217
|
+
}
|
|
218
|
+
return cells;
|
|
219
|
+
}
|
|
159
220
|
|
|
160
221
|
/** The first `width` visible characters, with escape sequences left intact. */
|
|
161
222
|
export function sliceVis(s, width) {
|
|
@@ -166,9 +227,17 @@ export function sliceVis(s, width) {
|
|
|
166
227
|
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
167
228
|
if (m) { out += m[0]; i += m[0].length - 1; continue; }
|
|
168
229
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
230
|
+
// A wide character that would straddle the edge is left off entirely:
|
|
231
|
+
// half of one is a replacement glyph in most terminals and a torn border
|
|
232
|
+
// in the rest.
|
|
233
|
+
const cp = s.codePointAt(i);
|
|
234
|
+
const ch = String.fromCodePoint(cp);
|
|
235
|
+
const selector = s.codePointAt(i + ch.length) === 0xfe0f;
|
|
236
|
+
const w = selector ? 2 : charWidth(cp);
|
|
237
|
+
if (seen + w > width) break;
|
|
238
|
+
out += selector ? ch + String.fromCodePoint(0xfe0f) : ch;
|
|
239
|
+
i += (selector ? ch.length + 1 : ch.length) - 1;
|
|
240
|
+
seen += w;
|
|
172
241
|
}
|
|
173
242
|
return out;
|
|
174
243
|
}
|
|
@@ -229,8 +298,11 @@ export function wrapAnsi(text, width) {
|
|
|
229
298
|
}
|
|
230
299
|
}
|
|
231
300
|
if (text[i] === ' ') { lastSpace = line.length; lastSpaceSeen = seen; }
|
|
232
|
-
|
|
233
|
-
|
|
301
|
+
const cp = text.codePointAt(i);
|
|
302
|
+
const ch = String.fromCodePoint(cp);
|
|
303
|
+
line += ch;
|
|
304
|
+
i += ch.length - 1;
|
|
305
|
+
seen += charWidth(cp);
|
|
234
306
|
if (seen >= width) {
|
|
235
307
|
// Break at a word boundary unless that would leave a stub behind.
|
|
236
308
|
if (lastSpace > 0 && lastSpaceSeen > width * 0.4) flush(lastSpace);
|
|
@@ -348,12 +420,16 @@ export function planRows(items) {
|
|
|
348
420
|
const done = list.filter((i) => i?.done).length;
|
|
349
421
|
const current = list.findIndex((i) => !i?.done);
|
|
350
422
|
|
|
351
|
-
|
|
423
|
+
// One left edge for the whole transcript: markers in column zero, every
|
|
424
|
+
// piece of content at column two. The plan used to sit at two and four, so
|
|
425
|
+
// three different margins ran down the page and the eye had no line to
|
|
426
|
+
// follow.
|
|
427
|
+
const rows = [`${progressBar(done, list.length)} ${sky(`${done}/${list.length}`)}`];
|
|
352
428
|
list.forEach((item, i) => {
|
|
353
429
|
const text = clip(String(item?.text ?? '').trim(), 64);
|
|
354
|
-
if (item?.done) rows.push(`
|
|
355
|
-
else if (i === current) rows.push(`
|
|
356
|
-
else rows.push(`
|
|
430
|
+
if (item?.done) rows.push(` ${theme.ok('✓')} ${dim(text)}`);
|
|
431
|
+
else if (i === current) rows.push(` ${blue('▸')} ${chalk.white(text)}`);
|
|
432
|
+
else rows.push(` ${dim('○')} ${dim(text)}`);
|
|
357
433
|
});
|
|
358
434
|
return rows;
|
|
359
435
|
}
|
|
@@ -73,6 +73,31 @@ useEffect(() => { localStorage.setItem("bill", bill); }, [bill]);
|
|
|
73
73
|
// next-themes — there is no "next-themes/dist/types". Import from "next-themes".
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
## Where a file goes
|
|
77
|
+
|
|
78
|
+
Routing is folder-based and it is **not** flexible: a page is only a route if
|
|
79
|
+
it sits at `src/app/<segment>/page.tsx`. A `page.tsx` anywhere else is an
|
|
80
|
+
ordinary file that nothing ever renders.
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
src/
|
|
84
|
+
app/
|
|
85
|
+
layout.tsx the shell, already written
|
|
86
|
+
page.tsx /
|
|
87
|
+
globals.css the design tokens — re-tint these
|
|
88
|
+
expenses/page.tsx /expenses
|
|
89
|
+
api/expenses/route.ts GET/POST /api/expenses
|
|
90
|
+
components/
|
|
91
|
+
expenses/list.tsx feature components, one per file
|
|
92
|
+
ui/ the 33 shadcn primitives, already here
|
|
93
|
+
lib/
|
|
94
|
+
store.ts data, schemas, anything the server owns
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Wrong, and it silently does nothing: `expenses/page.tsx` at the project root,
|
|
98
|
+
or `components/` beside `src/` instead of inside it. The app builds, the
|
|
99
|
+
route 404s, and nothing tells you why.
|
|
100
|
+
|
|
76
101
|
## Conventions
|
|
77
102
|
|
|
78
103
|
- Components are named exports — `export function BillInput()` — imported with
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
"dev": "next dev",
|
|
7
7
|
"build": "next build",
|
|
8
8
|
"start": "next start",
|
|
9
|
-
"lint": "eslint"
|
|
9
|
+
"lint": "eslint",
|
|
10
|
+
"test": "vitest run"
|
|
10
11
|
},
|
|
11
12
|
"dependencies": {
|
|
12
13
|
"class-variance-authority": "^0.7.1",
|
|
@@ -34,6 +35,7 @@
|
|
|
34
35
|
"eslint": "^9",
|
|
35
36
|
"eslint-config-next": "16.3.4",
|
|
36
37
|
"tailwindcss": "^4",
|
|
37
|
-
"typescript": "^5"
|
|
38
|
+
"typescript": "^5",
|
|
39
|
+
"vitest": "^3.2.4"
|
|
38
40
|
}
|
|
39
41
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The starter ships one passing test so there is a runner to add to rather
|
|
5
|
+
* than a decision to make. `npm test` works from the first minute, and
|
|
6
|
+
* ucode runs the tests that touch whatever it just changed — which only finds
|
|
7
|
+
* anything if tests exist.
|
|
8
|
+
*
|
|
9
|
+
* Delete this once there is something real to assert.
|
|
10
|
+
*/
|
|
11
|
+
describe('__APP_NAME__', () => {
|
|
12
|
+
it('has a test runner wired up', () => {
|
|
13
|
+
expect(true).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
});
|