buninu-linux 0.14.57 → 0.15.58

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.
Files changed (94) hide show
  1. package/LICENSES/linux-firmware/LICENCE.nvidia +131 -0
  2. package/LICENSES/linux-firmware/LICENSE.amdgpu +51 -0
  3. package/LICENSES/linux-firmware/LICENSE.amdisp +31 -0
  4. package/LICENSES/linux-firmware/LICENSE.i915 +39 -0
  5. package/LICENSES/linux-firmware/LICENSE.radeon +51 -0
  6. package/LICENSES/linux-firmware/LICENSE.xe +39 -0
  7. package/LICENSES/linux-firmware/WHENCE +10280 -0
  8. package/NOTICE.md +64 -46
  9. package/README.md +371 -87
  10. package/README.zh.md +2034 -0
  11. package/build-image.sh +10 -1
  12. package/build-uki.sh +3 -1
  13. package/fetch-alpine.sh +14 -0
  14. package/graphics.md +31 -0
  15. package/index.js +28 -11
  16. package/initramfs/bin/buninu-browser +4 -1
  17. package/initramfs/bin/buninu-land +1 -1
  18. package/initramfs/bin/bunterm +3 -3
  19. package/initramfs/bin/chroot +13 -0
  20. package/initramfs/bin/chvt +11 -0
  21. package/initramfs/bin/mount +4 -0
  22. package/initramfs/bin/umount +6 -0
  23. package/initramfs/bin/xdg-open +33 -0
  24. package/initramfs/buninu/CHANGELOG.md +19 -0
  25. package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +4 -0
  26. package/initramfs/buninu/apps/bunmsh/package.json +1 -1
  27. package/initramfs/buninu/apps/bunmsh/src/main.js +1 -1
  28. package/initramfs/buninu/apps/jsgotty/README.en.md +40 -0
  29. package/initramfs/buninu/apps/jsgotty/README.md +40 -0
  30. package/initramfs/buninu/apps/jsgotty/package-lock.json +2 -2
  31. package/initramfs/buninu/apps/jsgotty/package.json +1 -1
  32. package/initramfs/buninu/apps/jsgotty/static/index.html +1 -0
  33. package/initramfs/buninu/apps/jsgotty/static/js/gotty.js +82 -17
  34. package/initramfs/buninu/apps/jsgotty/static/js/kitty-keyboard.js +193 -0
  35. package/initramfs/buninu/apps/jsgotty/tests/android-alt.test.js +115 -0
  36. package/initramfs/buninu/apps/jsgotty/tests/arrow-modifiers.test.js +80 -0
  37. package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard-browser.test.js +53 -0
  38. package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard.test.js +102 -0
  39. package/initramfs/buninu/apps/jsgotty/tests/manual/README.md +20 -0
  40. package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-collector.cjs +55 -0
  41. package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-debug.js +74 -0
  42. package/initramfs/buninu/apps/jsgotty/tests/manual/kitty-keyboard.js +80 -0
  43. package/initramfs/buninu/package.json +1 -1
  44. package/initramfs/init.js +19 -3
  45. package/initramfs/lib/bunterm/clipboard.js +14 -0
  46. package/initramfs/lib/bunterm/commands.js +37 -0
  47. package/initramfs/lib/bunterm/fps.js +38 -0
  48. package/initramfs/lib/bunterm/images.js +9 -5
  49. package/initramfs/lib/bunterm/input.js +58 -15
  50. package/initramfs/lib/bunterm/keyboard.js +62 -0
  51. package/initramfs/lib/bunterm/keycodes.js +104 -0
  52. package/initramfs/lib/bunterm/kitty-keyboard.cjs +195 -0
  53. package/initramfs/lib/bunterm/raw-protocol.js +33 -0
  54. package/initramfs/lib/bunterm/renderer.js +89 -20
  55. package/initramfs/lib/bunterm/selection.js +60 -0
  56. package/initramfs/lib/bunterm/terminal.js +146 -11
  57. package/initramfs/lib/bunterm/vt-menu.js +181 -0
  58. package/initramfs/lib/bunterm/vt.js +27 -1
  59. package/initramfs/lib/canvas.js +99 -0
  60. package/initramfs/lib/chvt.js +31 -0
  61. package/initramfs/lib/fbdev.js +21 -3
  62. package/initramfs/lib/gpu.js +77 -0
  63. package/initramfs/lib/raw-image.js +47 -0
  64. package/initramfs/lib/xdg-open.js +42 -0
  65. package/initramfs/usr/share/doc/buninu-linux/bunterm.md +85 -11
  66. package/initramfs/usr/share/doc/buninu-linux/chvt.md +34 -0
  67. package/initramfs/usr/share/doc/buninu-linux/xdg-open.md +43 -0
  68. package/package.json +1 -1
  69. package/scripts/arch.js +7 -5
  70. package/scripts/arch.sh +5 -5
  71. package/scripts/build.js +16 -3
  72. package/scripts/fetch-gpu.js +105 -0
  73. package/scripts/fetch.js +11 -1
  74. package/scripts/gpu-firmware.json +22 -0
  75. package/scripts/modules.js +4 -1
  76. package/scripts/npm-packages.md +17 -17
  77. package/test/bunterm-fps.test.js +17 -0
  78. package/test/bunterm-keyboard.test.js +116 -0
  79. package/test/bunterm-next-vt.test.js +9 -0
  80. package/test/bunterm-raw-protocol.test.js +19 -0
  81. package/test/bunterm-renderer.test.js +158 -0
  82. package/test/bunterm-selection.test.js +38 -0
  83. package/test/bunterm-vt-menu.test.js +178 -0
  84. package/test/chroot-bind.test.js +40 -2
  85. package/test/chvt.test.js +46 -0
  86. package/test/fbdev-sync.test.js +33 -0
  87. package/test/gpu.test.js +155 -0
  88. package/test/image-size.test.js +17 -0
  89. package/test/manual-bunterm-image-paths.js +31 -0
  90. package/test/mount-libc-lifetime.test.js +50 -0
  91. package/test/no-gpu.test.js +43 -0
  92. package/test/readme-cli.test.js +29 -0
  93. package/test/stage-npm.test.js +11 -4
  94. package/test/xdg-open.test.js +55 -0
@@ -0,0 +1,178 @@
1
+ import { test, expect } from "bun:test";
2
+ import { createVtMenu } from "../initramfs/lib/bunterm/vt-menu.js";
3
+ import { createCommands } from "../initramfs/lib/bunterm/commands.js";
4
+ const report = (x, y, pressed = [], released = []) => ({ x, y, pressed, released, buttons: {left: pressed.includes("left")}, wheel: 0, moved: false });
5
+ function fixture(extra = {}) {
6
+ const selected = [];
7
+ const menu = createVtMenu({ width: 800, height: 600, rowHeight: 16,
8
+ list: () => [{number:1,label:"initial"}, {number:3,label:"bunterm"}],
9
+ select: n => selected.push(n), changed() {}, ...extra });
10
+ return {menu, selected};
11
+ }
12
+ test("new bunterm is below the tty list, inherits live font size, and prevents double launch", async () => {
13
+ let size=13, finish; const calls=[];
14
+ const {menu,selected}=fixture({controls:{fontSize:()=>size,execute:()=>""},
15
+ create:n=>{calls.push(n);return new Promise(resolve=>{finish=resolve;});}});
16
+ menu.pointer(report(0,0,["left"]));
17
+ const labels=[];
18
+ menu.draw({cellWidth:8,cellHeight:16,CanvasKit:{XYWHRect:(...r)=>r,ClipOp:{Intersect:0}},
19
+ paintFor:c=>c,drawText:text=>labels.push(text),
20
+ canvas:{save(){},restore(){},clipRect(){},drawRect(){}}});
21
+ expect(labels.indexOf("+ 新增 bunterm / New bunterm")).toBe(labels.indexOf("bunterm",2)+1);
22
+ size=20;
23
+ menu.input(Buffer.from("\x1b[B".repeat(5)+"\r\r"));
24
+ await Bun.sleep(0);
25
+ expect(calls).toEqual([20]);expect(selected).toEqual([]);
26
+ finish();await Bun.sleep(0);expect(menu.visible).toBe(false);
27
+ });
28
+
29
+ test("failed new bunterm launch keeps the menu usable", async () => {
30
+ const {menu}=fixture({controls:{fontSize:()=>13,execute:()=>""},create:()=>{throw Error("No next tty available");}});
31
+ menu.pointer(report(0,0,["left"]));
32
+ menu.input(Buffer.from("\x1b[B".repeat(5)+"\r"));
33
+ await Bun.sleep(0);expect(menu.visible).toBe(true);
34
+ const labels=[];
35
+ menu.draw({cellWidth:8,cellHeight:16,CanvasKit:{XYWHRect:(...r)=>r,ClipOp:{Intersect:0}},
36
+ paintFor:c=>c,drawText:text=>labels.push(text),
37
+ canvas:{save(){},restore(){},clipRect(){},drawRect(){}}});
38
+ expect(labels).toContain("No next tty available");
39
+ menu.input(Buffer.from("\x1b"));expect(menu.visible).toBe(false);
40
+ });
41
+ test("only the top-left four pixels open the menu; open and selection clicks never leak", () => {
42
+ const {menu,selected} = fixture();
43
+ expect(menu.pointer(report(2,1,["left"]))).toBe(false);
44
+ expect(menu.pointer(report(1,2,["left"]))).toBe(false);
45
+ expect(menu.pointer(report(1,1,["right"]))).toBe(false);
46
+ expect(menu.pointer(report(1,1,["left"]))).toBe(true);
47
+ expect(menu.visible).toBe(true);
48
+ expect(menu.pointer(report(1,1,[],["left"]))).toBe(true);
49
+ expect(menu.pointer(report(20,55,["left"]))).toBe(true);
50
+ expect(selected).toEqual([3]);
51
+ expect(menu.visible).toBe(false);
52
+ expect(menu.pointer(report(20,55,[],["left"]))).toBe(true);
53
+ expect(menu.pointer(report(30,60))).toBe(false);
54
+ });
55
+ test("menu consumes application input, supports keyboard selection and dismissal", () => {
56
+ const {menu,selected} = fixture();
57
+ menu.pointer(report(0,0,["left"]));
58
+ expect(menu.input(Buffer.from("shell command"))).toBe(true);
59
+ menu.input(Buffer.from("\x1b[B"));
60
+ menu.input(Buffer.from("\r"));
61
+ expect(selected).toEqual([3]);
62
+ menu.pointer(report(0,0,[],["left"]));
63
+ menu.pointer(report(0,0,["left"]));
64
+ menu.input(Buffer.from("\x1b"));
65
+ expect(menu.visible).toBe(false);
66
+ expect(menu.input(Buffer.from("x"))).toBe(false);
67
+ });
68
+ test("short screens scroll selection; renderer overlays are clipped", () => {
69
+ const {menu,selected} = fixture({height:72});
70
+ menu.pointer(report(0,0,["left"]));
71
+ menu.pointer({...report(10,10), wheel:-1});
72
+ const text=[];
73
+ menu.draw({cellWidth:8, cellHeight:16, CanvasKit:{XYWHRect:(...r)=>r,ClipOp:{Intersect:0}},
74
+ paintFor: c=>c, drawText: t=>text.push(t),
75
+ canvas:{save(){},restore(){},clipRect(){},drawRect(){}}});
76
+ expect(text).toContain("bunterm");
77
+ expect(text).not.toContain("initial");
78
+ menu.input(Buffer.from("\r"));
79
+ expect(selected).toEqual([3]);
80
+ });
81
+ test("outside click dismisses without selecting or leaking its release", () => {
82
+ const {menu,selected} = fixture();
83
+ menu.pointer(report(0,0,["left"]));
84
+ menu.pointer(report(0,0,[],["left"]));
85
+ expect(menu.pointer(report(700,300,["left"]))).toBe(true);
86
+ expect(menu.visible).toBe(false);
87
+ expect(menu.pointer(report(700,300,[],["left"]))).toBe(true);
88
+ expect(selected).toEqual([]);
89
+ });
90
+
91
+ test("Escape after releasing the opener does not eat the next application click", () => {
92
+ const {menu} = fixture();
93
+ menu.pointer(report(0,0,["left"]));
94
+ menu.pointer(report(0,0,[],["left"]));
95
+ menu.input(Buffer.from("\x1b"));
96
+ expect(menu.pointer(report(100,100,["left"]))).toBe(false);
97
+ });
98
+
99
+ test("font and clipboard buttons dispatch local commands; UTF-8 command editing survives split reads", async () => {
100
+ const calls=[];
101
+ const {menu}=fixture({controls:{fontSize:()=>13,execute:async line=>{calls.push(line);return "OK";}}});
102
+ menu.pointer(report(0,0,["left"]));
103
+ menu.pointer(report(0,0,[],["left"]));
104
+ for (const [x,y] of [[10,30],[400,30],[10,55],[300,55],[10,80]]) {
105
+ menu.pointer(report(x,y,["left"]));
106
+ menu.pointer(report(x,y,[],["left"]));
107
+ }
108
+ const bytes=Buffer.from("font 16中文");
109
+ menu.input(bytes.subarray(0,9)); menu.input(bytes.subarray(9));
110
+ menu.input(Buffer.from("\x7f\x7f\r")); // remove the two full code points
111
+ await new Promise(resolve=>setTimeout(resolve,0));
112
+ expect(calls).toEqual(["font -","font +","copy","paste","font 16"]);
113
+ expect(menu.visible).toBe(true);
114
+ menu.input(Buffer.from("\x1b"));
115
+ });
116
+
117
+ test("command registry validates font/VT arguments and is extensible", async () => {
118
+ let size=13; const calls=[];
119
+ const c=createCommands({fontSize:()=>size,setFontSize:n=>{if(!Number.isInteger(n)||n<6||n>96)throw Error("size");size=n;},
120
+ copy:()=>calls.push("copy"),paste:()=>calls.push("paste"),switchVT:n=>calls.push(n)});
121
+ expect(await c.execute("font +")).toBe("Font: 14");
122
+ await c.execute("font 18"); expect(size).toBe(18);
123
+ await expect(c.execute("font nope")).rejects.toThrow();
124
+ await expect(c.execute("vt 64")).rejects.toThrow();
125
+ await expect(c.execute("copy extra")).rejects.toThrow();
126
+ await expect(c.execute("unknown")).rejects.toThrow("try help");
127
+ await c.execute("copy"); await c.execute("paste"); await c.execute("vt 3");
128
+ expect(calls).toEqual(["copy","paste",3]);
129
+ c.commands.set("example", args=>args.join(" "));
130
+ expect(await c.execute("example hello world")).toBe("hello world");
131
+ });
132
+
133
+ test("menu labels current VT and renders a real left-aligned command field with centered font value", () => {
134
+ const {menu}=fixture({list:()=>[{number:1,label:"tty1 — initial console",current:true}],
135
+ controls:{fontSize:()=>13,execute:()=>""}});
136
+ menu.pointer(report(0,0,["left"]));
137
+ const labels=[];
138
+ menu.draw({cellWidth:8,cellHeight:16,CanvasKit:{XYWHRect:(...r)=>r,ClipOp:{Intersect:0}},
139
+ paintFor:c=>c,drawText:(text,x,y,cells,color,options)=>labels.push({text,align:options.align}),
140
+ canvas:{save(){},restore(){},clipRect(){},drawRect(){}}});
141
+ expect(labels).toContainEqual({text:"tty1 — initial console (current)",align:"left"});
142
+ expect(labels).toContainEqual({text:"Click to type a command…",align:"left"});
143
+ expect(labels).toContainEqual({text:"🔤 字體 Font 13",align:"center"});
144
+ expect(labels).toContainEqual({text:"📋 複製 Copy",align:"left"});
145
+ expect(labels).toContainEqual({text:"📥 貼上 Paste",align:"left"});
146
+ expect(labels.some(x=>x.text===">")).toBe(false);
147
+ });
148
+
149
+ test("font buttons fit equally and preserve the right margin as fonts grow", () => {
150
+ const {menu}=fixture({controls:{fontSize:()=>20,execute:()=>""}});
151
+ menu.pointer(report(0,0,["left"]));
152
+ for(const cellWidth of [8,12,19,58]) {
153
+ const labels=[];
154
+ menu.draw({cellWidth,cellHeight:24,CanvasKit:{XYWHRect:(...r)=>r,ClipOp:{Intersect:0}},
155
+ paintFor:c=>c,drawText:(text,x,y,cells,color,options)=>labels.push({text,x,cells,align:options.align}),
156
+ canvas:{save(){},restore(){},clipRect(){},drawRect(){}}});
157
+ const plus=labels.find(x=>x.text==="[+]"),minus=labels.find(x=>x.text==="[-]");
158
+ expect(plus.align).toBe("center");expect(minus.align).toBe("center");
159
+ expect(plus.cells).toBe(minus.cells);
160
+ expect(plus.x+plus.cells*cellWidth).toBe(412);
161
+ expect(minus.x).toBe(8);
162
+ }
163
+ });
164
+
165
+ test('header FPS control toggles without selecting a tty or leaking the click',()=>{
166
+ let enabled=false;
167
+ const {menu,selected}=fixture({controls:{fontSize:()=>13,execute:()=>'',fpsEnabled:()=>enabled,toggleFps:()=>{enabled=!enabled;}}});
168
+ menu.pointer(report(0,0,['left']));
169
+ menu.pointer(report(0,0,[],['left']));
170
+ expect(menu.pointer(report(380,12,['left']))).toBe(true);
171
+ expect(enabled).toBe(true);expect(menu.visible).toBe(true);expect(selected).toEqual([]);
172
+ menu.pointer(report(380,12,[],['left']));
173
+ const labels=[];
174
+ menu.draw({cellWidth:8,cellHeight:16,CanvasKit:{XYWHRect:(...r)=>r,ClipOp:{Intersect:0}},
175
+ paintFor:c=>c,drawText:text=>labels.push(text),canvas:{save(){},restore(){},clipRect(){},drawRect(){}}});
176
+ expect(labels).toContain('[FPS ✓]');
177
+ menu.pointer(report(380,12,['left']));expect(enabled).toBe(false);
178
+ });
@@ -39,8 +39,21 @@ async function invoke(argumentsFor,env={}) {
39
39
  const mock=join(dir,'libc.js');
40
40
  writeFileSync(mock,`import {appendFileSync} from 'node:fs';
41
41
  const record=(name,...args)=>appendFileSync(${JSON.stringify(log)},JSON.stringify([name,...args.map(a=>typeof a==='bigint'?String(a):a)])+'\\n');
42
- export const ptr=x=>x,cString=x=>x,errno=()=>1,strerror=()=> 'mock mount denied',Errno={EINVAL:22,EPERM:1};
43
- export const libc={symbols:{mount:(...a)=>{record('mount',...a);return a[0]?.endsWith('/fail')?-1:0;},umount2:(...a)=>{record('umount',...a);return 0;},chroot:(...a)=>{record('chroot',...a);return 0;}}};`);
42
+ let entered=false;
43
+ const bound=new Map();
44
+ const functions={mount:(...a)=>{record('mount',...a);return a[0]?.endsWith('/fail')?-1:0;},
45
+ umount2:(...a)=>{record('umount',...a);return process.env.MOCK_UNMOUNT_FAIL?-1:0;},
46
+ chroot:(...a)=>{record('chroot',...a);entered=true;return 0;},
47
+ strerror:()=> 'mock mount denied',errno:()=>process.env.MOCK_UNMOUNT_FAIL?5:1};
48
+ export const libc={symbols:new Proxy({}, {get(_target,name){
49
+ if(!bound.has(name)){
50
+ record('resolve',name);
51
+ if(entered||process.env.MOCK_MISSING_SYMBOL===name)throw Error('Cannot load libc symbol '+name);
52
+ bound.set(name,functions[name]);
53
+ }
54
+ return bound.get(name);
55
+ }})};
56
+ export const ptr=x=>x,cString=x=>x,errno=()=>libc.symbols.errno(),strerror=code=>libc.symbols.strerror(code),Errno={EINVAL:22,EPERM:1};`);
44
57
  const source=readFileSync(new URL('../initramfs/bin/chroot',import.meta.url),'utf8')
45
58
  .replace('"../lib/dlopen.js"',JSON.stringify(mock))
46
59
  .replace('"../lib/chroot-options.js"',JSON.stringify(new URL('../initramfs/lib/chroot-options.js',import.meta.url).pathname))
@@ -68,3 +81,28 @@ test('shared tmp replaces default tmpfs and bind failure cleans up even with -k'
68
81
  expect(failure.calls.some(c=>c[0]==='chroot')).toBe(false);
69
82
  expect(failure.calls.filter(c=>c[0]==='umount').map(c=>c[1])).toEqual([failure.root+'/first']);
70
83
  });
84
+
85
+ test('cleanup symbols are resolved before mounting and remain usable after chroot',async()=>{
86
+ const r=await invoke(({host})=>['-n','-b',`${host}:/work`],{MOCK_UNMOUNT_FAIL:'1'});
87
+ expect(r.status).toBe(0);
88
+ expect(r.stderr).toContain('chroot: unmount /work: mock mount denied');
89
+ expect(r.stderr).not.toContain('Cannot load libc symbol');
90
+ const firstMount=r.calls.findIndex(c=>c[0]==='mount');
91
+ for(const name of ['umount2','strerror','errno']){
92
+ const index=r.calls.findIndex(c=>c[0]==='resolve'&&c[1]===name);
93
+ expect(index).toBeGreaterThanOrEqual(0);expect(index).toBeLessThan(firstMount);
94
+ }
95
+ expect(r.calls.filter(c=>c[0]==='umount').map(c=>c[2])).toEqual([0,2]);
96
+ });
97
+
98
+ test('missing cleanup symbol fails before any mount, including keep-mounts',async()=>{
99
+ const r=await invoke(({host})=>['-n','-k','-b',`${host}:/work`],{MOCK_MISSING_SYMBOL:'umount2'});
100
+ expect(r.status).toBe(125);expect(r.stderr).toContain('Cannot load libc symbol umount2');
101
+ expect(r.calls.some(c=>['mount','chroot','umount'].includes(c[0]))).toBe(false);
102
+ });
103
+
104
+ test('no-mount chroot without binds does not require umount2',async()=>{
105
+ const r=await invoke(()=>['-n'],{MOCK_MISSING_SYMBOL:'umount2'});
106
+ expect(r.status).toBe(0);
107
+ expect(r.calls.some(c=>c[0]==='resolve'&&c[1]==='umount2')).toBe(false);
108
+ });
@@ -0,0 +1,46 @@
1
+ import { test, expect } from "bun:test";
2
+ import { parseChvt, switchVt } from "../initramfs/lib/chvt.js";
3
+
4
+ test("chvt accepts VT numbers and help, rejects invalid input before opening devices", async () => {
5
+ expect(parseChvt(["2"])).toEqual({ number: 2 });
6
+ expect(parseChvt(["63"])).toEqual({ number: 63 });
7
+ for (const flag of ["-h", "--help"]) expect(parseChvt([flag]).help).toBe(true);
8
+ for (const args of [[], ["0"], ["64"], ["-1"], ["2x"], ["1", "2"]]) expect(() => parseChvt(args)).toThrow();
9
+ await expect(switchVt(0, {})).rejects.toThrow();
10
+ });
11
+
12
+ test("chvt skips PTY/non-VT consoles, activates only a validated VT and closes descriptors", async () => {
13
+ const events = [];
14
+ await switchVt(2, {
15
+ openConsole([path]) {
16
+ events.push(path);
17
+ if (path === "/dev/tty0") throw Error("missing");
18
+ return { path, close: () => events.push(`close ${path}`) };
19
+ },
20
+ activeConsole(device) { if (device.path === "/dev/tty") throw Error("not a VT"); },
21
+ activateConsole(device, number) { events.push(`activate ${device.path} ${number}`); },
22
+ });
23
+ expect(events).toEqual(["/dev/tty0", "/dev/tty", "close /dev/tty", "/dev/console", "activate /dev/console 2", "close /dev/console"]);
24
+ });
25
+
26
+ test("chvt closes console on failed switch without overriding VT owner or trying another switch", async () => {
27
+ let closed = 0, opened = 0;
28
+ await expect(switchVt(1, {
29
+ openConsole() { opened++; return { close() { closed++; } }; },
30
+ activeConsole() { return 2; },
31
+ activateConsole() { throw Error("switch denied"); },
32
+ })).rejects.toThrow("switch denied");
33
+ expect(opened).toBe(1);
34
+ expect(closed).toBe(1);
35
+ });
36
+
37
+ test("chvt CLI help and invalid arguments work without a Linux console", () => {
38
+ for (const flag of ["-h", "--help"]) {
39
+ const result = Bun.spawnSync([process.execPath, "initramfs/bin/chvt", flag]);
40
+ expect(result.exitCode).toBe(0);
41
+ expect(result.stdout.toString()).toContain("bunterm");
42
+ }
43
+ const invalid = Bun.spawnSync([process.execPath, "initramfs/bin/chvt", "0"]);
44
+ expect(invalid.exitCode).toBe(1);
45
+ expect(invalid.stderr.toString()).toContain("usage:");
46
+ });
@@ -0,0 +1,33 @@
1
+ import { test, expect } from "bun:test";
2
+ import { syncFramebuffer, flush } from "../initramfs/lib/fbdev.js";
3
+ const display = () => ({ address: 0x10000, memoryLength: 4096, path: "/dev/fb0" });
4
+ test("unsupported framebuffer msync is probed once per mapping", () => {
5
+ for (const code of [22, 95]) {
6
+ const fb = display(); let calls = 0;
7
+ const native = { msync(...args) { calls++; expect(args).toEqual([0x10000, 4096, 4]); return -1; }, errno: () => code };
8
+ syncFramebuffer(fb, native); syncFramebuffer(fb, native);
9
+ expect(calls).toBe(1);
10
+ expect(fb.msyncUnsupported).toBe(true);
11
+ }
12
+ });
13
+ test("supported sync continues every frame and retries EINTR", () => {
14
+ const fb = display(); let calls = 0;
15
+ const native = { msync() { return ++calls === 1 ? -1 : 0; }, errno: () => 4 };
16
+ syncFramebuffer(fb, native); syncFramebuffer(fb, native);
17
+ expect(calls).toBe(3);
18
+ expect(fb.msyncUnsupported).toBeUndefined();
19
+ });
20
+ test("real mapping and I/O failures remain visible", () => {
21
+ for (const code of [5, 12, 9]) {
22
+ const fb = display();
23
+ expect(() => syncFramebuffer(fb, {msync: () => -1, errno: () => code, strerror: () => `error ${code}`})).toThrow(`error ${code}`);
24
+ expect(fb.msyncUnsupported).toBeUndefined();
25
+ }
26
+ });
27
+ test("pixel writes continue on unsupported sync and respect framebuffer offsets and stride", () => {
28
+ const fb = {...display(), msyncUnsupported: true, width: 2, height: 2, bytesPerPixel: 1,
29
+ stride: 4, xOffset: 1, yOffset: 1, pixels: new Uint8Array([1,2,0,0,3,4,0,0]), memory: new Uint8Array(16)};
30
+ flush(fb);
31
+ expect([...fb.memory]).toEqual([0,0,0,0,0,1,2,0,0,3,4,0,0,0,0,0]);
32
+ fb.pixels[0] = 9; flush(fb); expect(fb.memory[5]).toBe(9);
33
+ });
@@ -0,0 +1,155 @@
1
+ import { test, expect, afterEach } from "bun:test";
2
+ import * as fs from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { configureGpu, framebufferUsers, isGpuPath } from "../initramfs/lib/gpu.js";
6
+ import { extractFirmware, firmwareLinkTarget } from "../scripts/fetch-gpu.js";
7
+ import { readTar } from "../scripts/fetch.js";
8
+ import { requests, select } from "../scripts/modules.js";
9
+
10
+ const dirs = [];
11
+ const temporary = () => { const p = fs.mkdtempSync(join(tmpdir(), "buninu-gpu-test-")); dirs.push(p); return p; };
12
+ afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
13
+ const archive = (files, links = {}) => ({ entries: () => Object.entries(files).map(([n,b]) => [n, Buffer.from(b)]), links: () => Object.entries(links) });
14
+ const soft = target => ({ target, hard: false });
15
+
16
+ test("firmware preserves directory aliases, cross-package links and hardlinks", () => {
17
+ const root = temporary();
18
+ const files = extractFirmware([
19
+ archive({ "lib/firmware/nvidia/ad102/gsp.bin.zst": "firmware", "lib/firmware/amdgpu/a.bin.zst": "amd" }),
20
+ archive({}, {
21
+ "lib/firmware/nvidia/ad103": soft("ad102"),
22
+ "lib/firmware/nvidia/alias.bin.zst": soft("ad103/gsp.bin.zst"),
23
+ "lib/firmware/radeon/alias.bin.zst": soft("../amdgpu/a.bin.zst"),
24
+ "lib/firmware/nvidia/hard.bin.zst": { target: "lib/firmware/nvidia/ad102/gsp.bin.zst", hard: true },
25
+ }),
26
+ ], root);
27
+ expect(files).toHaveLength(6);
28
+ expect(fs.readFileSync(join(root, "lib/firmware/nvidia/ad103/gsp.bin.zst"), "utf8")).toBe("firmware");
29
+ expect(fs.readFileSync(join(root, "lib/firmware/nvidia/alias.bin.zst"), "utf8")).toBe("firmware");
30
+ expect(fs.readFileSync(join(root, "lib/firmware/nvidia/hard.bin.zst"), "utf8")).toBe("firmware");
31
+ expect(fs.readFileSync(join(root, "lib/firmware/radeon/alias.bin.zst"), "utf8")).toBe("amd");
32
+ });
33
+
34
+ test("firmware rejects escaping, missing and cyclic links before extraction", () => {
35
+ for (const target of ["/etc/passwd", "../../../etc/passwd", "..\\etc\\passwd"])
36
+ expect(() => firmwareLinkTarget("lib/firmware/nvidia/x", soft(target))).toThrow();
37
+ for (const links of [
38
+ { "lib/firmware/a": soft("missing") },
39
+ { "lib/firmware/a": soft("b"), "lib/firmware/b": soft("a") },
40
+ { "lib/firmware/a": soft("a/child") },
41
+ ]) expect(() => extractFirmware([archive({}, links)], temporary())).toThrow();
42
+ expect(() => extractFirmware([archive({"lib/firmware/../../escape": "bad"})], temporary())).toThrow();
43
+ });
44
+
45
+ test.skipIf(!Bun.which("tar"))("APK tar reader retains GNU long symlinks and hardlinks", () => {
46
+ const root = temporary(), tree = join(root, "tree"); fs.mkdirSync(tree);
47
+ const name = "x".repeat(120);
48
+ fs.writeFileSync(join(tree, name), "firmware");
49
+ fs.linkSync(join(tree, name), join(tree, "hard"));
50
+ fs.symlinkSync(name, join(tree, "alias"));
51
+ const pkg = join(root, "package.tar.gz");
52
+ expect(Bun.spawnSync(["tar", "--format=gnu", "-czf", pkg, "-C", tree, name, "hard", "alias"]).exitCode).toBe(0);
53
+ const tar = readTar(pkg), links = new Map(tar.links());
54
+ expect(tar.get(name).toString()).toBe("firmware");
55
+ expect(links.get("alias")).toEqual({ target: name, hard: false });
56
+ expect(links.get("hard")).toEqual({ target: name, hard: true });
57
+ });
58
+
59
+ const runtime = () => {
60
+ const root = temporary(), calls = [], logs = [];
61
+ fs.mkdirSync(join(root, "proc/1/fd"), { recursive:true });
62
+ fs.writeFileSync(join(root, "proc/1/maps"), "");
63
+ fs.mkdirSync(join(root, "dev/dri"), { recursive:true });
64
+ fs.writeFileSync(join(root, "dev/dri/renderD129"), "node");
65
+ fs.writeFileSync(join(root, "dev/fb0"), "node");
66
+ const io = {...fs,
67
+ statSync: path => path.includes("renderD") ? { isCharacterDevice: () => true } : fs.statSync(path),
68
+ chownSync: (path, uid, gid) => calls.push(["chown", uid, gid]),
69
+ chmodSync: (path, mode) => calls.push(["chmod", mode]),
70
+ };
71
+ const options = { root, io, env: {}, log: s => logs.push(s),
72
+ tryModprobe: name => calls.push(["transport", name]),
73
+ autoload: options => {
74
+ const candidates = [ ["amdgpu", "kernel/drivers/gpu/drm/amd/amdgpu/amdgpu.ko"], ["simpledrm", "kernel/drivers/gpu/drm/sysfb/simpledrm.ko"], ["e1000", "kernel/drivers/net/e1000.ko"] ];
75
+ const modules = candidates.filter(x => options.acceptModule(...x)).map(x => x[0]);
76
+ calls.push(["autoload", modules]);
77
+ return [{ bus:"pci", device:"0000:01:00.0", modules, loaded:modules, error:null }];
78
+ },
79
+ };
80
+ return { root, calls, logs, options };
81
+ };
82
+
83
+ test("cfg.gpu matches only GPU leaf drivers and configures actual render node names", () => {
84
+ const { options, calls } = runtime(); options.env.BUNINU_RENDER_GID = "107";
85
+ const result = configureGpu(options);
86
+ expect(result.errors).toEqual([]);
87
+ expect(result.renderNodes).toEqual([{path:"/dev/dri/renderD129", driver:"unknown", gid:107}]);
88
+ expect(calls).toContainEqual(["autoload", ["amdgpu"]]);
89
+ expect(calls).toContainEqual(["chown", 0, 107]);
90
+ expect(calls).toContainEqual(["chmod", 0o660]);
91
+ });
92
+
93
+ test("virtio render nodes report the GPU child driver rather than the PCI transport", () => {
94
+ const { root, options } = runtime();
95
+ const device = join(root, "sys/class/drm/renderD129/device");
96
+ fs.mkdirSync(join(device, "virtio0"), {recursive:true});
97
+ fs.symlinkSync("/sys/bus/pci/drivers/virtio-pci", join(device, "driver"));
98
+ fs.symlinkSync("/sys/bus/virtio/drivers/virtio_gpu", join(device, "virtio0/driver"));
99
+ expect(configureGpu(options).renderNodes[0].driver).toBe("virtio_gpu");
100
+ });
101
+
102
+ test("no matching GPU and no render node is diagnosed without claiming acceleration", () => {
103
+ const { root, options, logs } = runtime();
104
+ fs.unlinkSync(join(root, "dev/dri/renderD129"));
105
+ options.autoload = () => [];
106
+ const result = configureGpu(options);
107
+ expect(result.errors).toEqual([]);
108
+ expect(result.renderNodes).toEqual([]);
109
+ expect(logs.some(s=>s.includes("no accessible render node"))).toBe(true);
110
+ });
111
+
112
+ test("framebuffer fd or surviving mmap prevents GPU takeover before loading anything", () => {
113
+ for (const mapped of [false, true]) {
114
+ const { root, options, calls } = runtime();
115
+ if (mapped) fs.writeFileSync(join(root, "proc/1/maps"), "abc-def rw-s 00000000 00:01 3 /dev/fb0\n");
116
+ else fs.symlinkSync("/dev/fb0", join(root, "proc/1/fd/4"));
117
+ expect(framebufferUsers(options)).toEqual(["1"]);
118
+ expect(configureGpu(options).blocked).toBe(true);
119
+ expect(calls).toEqual([]);
120
+ }
121
+ });
122
+
123
+ test("missing hardware, driver failure and invalid permissions do not escape cfg.gpu", () => {
124
+ const { options, calls } = runtime();
125
+ options.env.BUNINU_RENDER_GID = "bad";
126
+ expect(configureGpu(options).errors.length).toBe(1); expect(calls).toEqual([]);
127
+ options.env = {};
128
+ options.autoload = () => { throw Error("driver failed"); };
129
+ expect(configureGpu(options).errors).toEqual(["driver failed"]);
130
+ options.autoload = () => []; options.io.chownSync = () => { throw Error("permission denied"); };
131
+ expect(configureGpu(options).errors[0]).toContain("permission denied");
132
+ });
133
+
134
+ test("cfg.all's actual loader excludes GPU modules and still loads network/sound", () => {
135
+ const source = fs.readFileSync(new URL("../initramfs/init.js", import.meta.url), "utf8");
136
+ const body = source.match(/const configureModules = \(\) => \{([\s\S]*?)\n\};/)[1];
137
+ const chosen = [];
138
+ const require = () => ({ isGpuPath, modprobeAll: ({accept}) => {
139
+ for (const path of ["kernel/drivers/gpu/drm/amd/amdgpu/amdgpu.ko", "kernel/drivers/net/e1000.ko", "kernel/sound/snd.ko"])
140
+ if (accept("",path)) chosen.push(path);
141
+ return {inserted:chosen,errors:[]};
142
+ }});
143
+ new Function("require", "console", body)(require, {log(){}});
144
+ expect(chosen).toEqual(["kernel/drivers/net/e1000.ko", "kernel/sound/snd.ko"]);
145
+ expect(source).toContain("gpu: { enumerable: true, get: configureGraphics }");
146
+ });
147
+
148
+ test("GPU leaves are optional for kernels without them and their dependencies are selected", () => {
149
+ const target = {arch:"x86_64",flavor:"lts",real:false};
150
+ const dep = requests(target).filter(x=>!x.optional).map(({name})=>`kernel/${name}.ko.gz:`);
151
+ dep.push("kernel/drivers/gpu/drm/amd/amdgpu/amdgpu.ko.gz: kernel/drivers/gpu/drm/ttm/ttm.ko.gz", "kernel/drivers/gpu/drm/ttm/ttm.ko.gz:");
152
+ const result = select({...target,dep:dep.join("\n"),builtin:"",packageName:"test"});
153
+ expect(result.selected).toContain("kernel/drivers/gpu/drm/ttm/ttm.ko.gz");
154
+ expect(result.notes.some(x=>x.includes("i915"))).toBe(true);
155
+ });
@@ -0,0 +1,17 @@
1
+ import { test, expect } from "bun:test";
2
+ import { imageBytes } from "../scripts/build.js";
3
+ import { layout } from "../initramfs/lib/fat.js";
4
+ const MiB = 1024 * 1024;
5
+ test("image sizing preserves small images and fits GPU firmware payloads plus FAT metadata", () => {
6
+ expect(imageBytes(80 * MiB)).toBe(128 * MiB);
7
+ // Actual no-gpu EFI: the former 16 MiB reserve incorrectly made this 192 MiB.
8
+ expect(imageBytes(113646592)).toBe(128 * MiB);
9
+ expect(imageBytes(266 * MiB)).toBe(320 * MiB);
10
+ for (const payload of [0, 100 * MiB, 120 * MiB, 240 * MiB, 266 * MiB, 512 * MiB, 0xffffffff]) {
11
+ const size = imageBytes(payload);
12
+ const geometry = layout(size / 512 - 34 - 2048 + 1);
13
+ expect((geometry.clusters - 3) * geometry.perCluster * 512).toBeGreaterThan(payload);
14
+ expect(size % (64 * MiB)).toBe(0);
15
+ }
16
+ expect(() => imageBytes(0x100000000)).toThrow("FAT32");
17
+ });
@@ -0,0 +1,31 @@
1
+ import {runGraphics} from '/lib/fbdev.js';
2
+ import {loadCanvasKit,openCanvas} from '/lib/canvas.js';
3
+ import {claimVirtualConsole} from '/lib/bunterm/vt.js';
4
+ // Manual native Linux benchmark; takes over tty6 temporarily.
5
+ // bun test/manual-bunterm-image-paths.js /path/to/1920x1080.png
6
+ if(!process.argv[2])throw Error('Pass a 1920x1080 PNG filename');
7
+ const CK=await loadCanvasKit(), results=[];
8
+ await runGraphics(async(display,{onRelease,onAcquire})=>{
9
+ const screen=openCanvas(CK,display);let active=true;onRelease(()=>active=false);onAcquire(()=>active=true);
10
+ const bytes=await Bun.file(process.argv[2]).bytes();
11
+ const image=CK.MakeImageFromEncoded(bytes),w=image.width(),h=image.height();
12
+ if(w!==1920||h!==1080||display.width!==1920||display.height!==1080||display.bitsPerPixel!==32)throw Error("This comparison requires a 1920x1080 image and 32-bit 1920x1080 framebuffer");
13
+ const src=CK.LTRBRect(0,0,w,h), native=CK.LTRBRect(0,0,w,h),scaled=CK.LTRBRect(0,26,1920,1066);
14
+ const info={width:w,height:h,colorType:CK.ColorType.RGBA_8888,alphaType:CK.AlphaType.Unpremul,colorSpace:CK.ColorSpace.SRGB};
15
+ const start=performance.now(),pixels=image.readPixels(0,0,info);
16
+ if(!pixels)throw Error('readPixels failed');
17
+ if(screen.colorType===CK.ColorType.BGRA_8888)for(let i=0;i<pixels.length;i+=4){const r=pixels[i];pixels[i]=pixels[i+2];pixels[i+2]=r;}results.push({readPixelsMs:performance.now()-start,width:w,height:h,bytes:pixels?.length});
18
+ const actions={
19
+ 'rows-scaled':()=>{for(let y=0;y<1080;y+=26){screen.canvas.save();screen.canvas.clipRect(CK.LTRBRect(0,y,1920,Math.min(1080,y+26)),CK.ClipOp.Intersect,false);screen.canvas.drawImageRectOptions(image,src,scaled,CK.FilterMode.Linear,CK.MipmapMode.Linear,null);screen.canvas.restore();}},
20
+ 'single-scaled':()=>screen.canvas.drawImageRectOptions(image,src,scaled,CK.FilterMode.Linear,CK.MipmapMode.Linear,null),
21
+ 'single-1to1':()=>screen.canvas.drawImageRectOptions(image,src,native,CK.FilterMode.Linear,CK.MipmapMode.Linear,null),
22
+ 'drawImage-1to1':()=>screen.canvas.drawImage(image,0,0),
23
+ 'decode-copy-1to1':()=>{const fresh=CK.MakeImageFromEncoded(bytes);try{const p=fresh.readPixels(0,0,info);if(!p)throw Error('decode failed');if(screen.colorType===CK.ColorType.BGRA_8888)for(let i=0;i<p.length;i+=4){const r=p[i];p[i]=p[i+2];p[i+2]=r;}screen.restoreRegion({x:0,y:0,height:Math.min(h,1080),rowBytes:w*4,pixels:p});}finally{fresh.delete();}},
24
+ 'copy-1to1':()=>screen.restoreRegion({x:0,y:0,height:Math.min(h,1080),rowBytes:w*4,pixels}),
25
+ };
26
+ try {for(const [name,draw] of Object.entries(actions)){
27
+ screen.canvas.clear(CK.BLACK);draw();screen.flush();
28
+ const t=performance.now();let count=0,total=0;while(performance.now()-t<2500){if(!active)throw Error('VT switched');const t0=performance.now();draw();screen.flush();total+=performance.now()-t0;count++;await Bun.sleep(16);}
29
+ results.push({name,ms:total/count,count});await Bun.write('/tmp/bunterm-image-paths.json',JSON.stringify(results,null,2));
30
+ }}finally{image.delete();screen.close();}
31
+ },{console:['/dev/tty6'],prepareConsole:claimVirtualConsole});process.exit(0);
@@ -0,0 +1,50 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {mkdtempSync,writeFileSync,readFileSync,rmSync} from 'node:fs';
3
+ import {tmpdir} from 'node:os';
4
+ import {join} from 'node:path';
5
+
6
+ // Run the real command with a libc whose path disappears after the first
7
+ // successful mount change. Never mount or unmount anything on the host.
8
+ async function invoke(name,args){
9
+ const dir=mkdtempSync(join(tmpdir(),'buninu-mount-libc-'));
10
+ const mock=join(dir,'libc.mjs');
11
+ writeFileSync(mock,`
12
+ let hidden=false;
13
+ const bound=new Map();
14
+ const functions={mount:()=>{if(hidden)return -1;hidden=true;return 0;},
15
+ umount2:()=>{if(hidden)return -1;hidden=true;return 0;},
16
+ errno:()=>5,strerror:()=> 'mock I/O error'};
17
+ export const libc={symbols:new Proxy({}, {get(_target,name){
18
+ if(!bound.has(name)){
19
+ if(hidden)throw Error('Cannot load libc symbol '+name);
20
+ bound.set(name,functions[name]);
21
+ }
22
+ return bound.get(name);
23
+ }})};
24
+ export const ptr=x=>x,cString=x=>x,errno=()=>libc.symbols.errno(),
25
+ strerror=code=>libc.symbols.strerror(code),Errno={},SysError=class extends Error{};
26
+ `);
27
+ const source=readFileSync(new URL('../initramfs/bin/'+name,import.meta.url),'utf8')
28
+ .replace('"../lib/dlopen.js"',JSON.stringify(mock))
29
+ .replace('import { showDocument } from "../lib/document.js";','const showDocument=async()=>{};')
30
+ .replace('import { modprobe, tryModprobe, modulesDirectory, ModuleNotFound } from "../lib/modprobe.js";',
31
+ 'const modprobe=()=>[],tryModprobe=()=>[],modulesDirectory=()=>"";class ModuleNotFound extends Error{}');
32
+ const entry=join(dir,'command.mjs');writeFileSync(entry,source);
33
+ try{
34
+ const child=Bun.spawn([process.execPath,entry,...args],{stdout:'pipe',stderr:'pipe'});
35
+ const stderr=new Response(child.stderr).text();
36
+ return {status:await child.exited,stderr:await stderr};
37
+ }finally{rmSync(dir,{recursive:true,force:true});}
38
+ }
39
+
40
+ test('read-only bind reports remount failure after the libc path is hidden',async()=>{
41
+ const r=await invoke('mount',['--bind','-o','ro','/fake-source','/fake-target']);
42
+ expect(r.status).toBe(32);expect(r.stderr).toContain('mount: mock I/O error');
43
+ expect(r.stderr).not.toContain('Cannot load libc symbol');
44
+ });
45
+
46
+ test('later unmount failure reports strerror after an earlier target hides libc',async()=>{
47
+ const r=await invoke('umount',['/fake-lib','/fake-missing']);
48
+ expect(r.status).toBe(32);expect(r.stderr).toContain('umount: /fake-missing: mock I/O error.');
49
+ expect(r.stderr).not.toContain('Cannot load libc symbol');
50
+ });
@@ -0,0 +1,43 @@
1
+ import {test,expect} from "bun:test";
2
+ import {mkdtempSync,mkdirSync,writeFileSync,existsSync,rmSync} from "node:fs";
3
+ import {tmpdir} from "node:os";
4
+ import {join} from "node:path";
5
+ import {requests,select} from "../scripts/modules.js";
6
+ import {config} from "../scripts/arch.js";
7
+ import {fetchGpuFirmware,gpuReady} from "../scripts/fetch-gpu.js";
8
+ const additions=new Set(["virtio-gpu","vmwgfx","i915","xe","amdgpu","radeon","nouveau"]);
9
+ test("no-gpu only removes new explicit requests across target configurations",()=>{
10
+ for(const arch of ["x86_64","aarch64"])for(const flavor of ["lts","virt"])for(const real of [true,false]){
11
+ const cfg={arch,flavor,real};
12
+ expect(requests({...cfg,noGpu:true})).toEqual(requests(cfg).filter(r=>!additions.has(r.name)));
13
+ }
14
+ expect(config({noGpu:true}).noGpu).toBe(true);
15
+ });
16
+ test("no-gpu retains a GPU-directory dependency when an original module needs it",()=>{
17
+ const cfg={arch:"x86_64",flavor:"lts",real:false,noGpu:true};
18
+ const dep=requests(cfg).map(({name})=>`kernel/test/${name}.ko.gz:${name==="snd-hda-intel"?" kernel/drivers/gpu/drm/shared.ko.gz":""}`).join("\n")+"\nkernel/drivers/gpu/drm/shared.ko.gz:";
19
+ expect(select({...cfg,dep,builtin:"",packageName:"test"}).selected).toContain("kernel/drivers/gpu/drm/shared.ko.gz");
20
+ });
21
+ test("no-gpu clears generated firmware and caches cannot cross GPU modes",async()=>{
22
+ const root=mkdtempSync(join(tmpdir(),"no-gpu-"));
23
+ try{
24
+ const cfg={...config({arch:"x86_64",flavor:"lts",real:true,noGpu:true}),nativeDir:root,modulesRoot:join(root,"modules")};
25
+ mkdirSync(cfg.modulesRoot,{recursive:true});
26
+ writeFileSync(join(cfg.modulesRoot,"modules.dep"),"");
27
+ writeFileSync(join(cfg.modulesRoot,"modules.builtin"),"kernel/drivers/gpu/drm/i915/i915.ko\n");
28
+ mkdirSync(join(root,"lib/firmware"),{recursive:true});
29
+ writeFileSync(join(root,"lib/firmware/stale"),"old firmware");
30
+ await fetchGpuFirmware(cfg);
31
+ expect(existsSync(join(root,"lib/firmware"))).toBe(false);
32
+ expect(gpuReady(cfg)).toBe(true);
33
+ expect(gpuReady({...cfg,noGpu:false})).toBe(false);
34
+ mkdirSync(join(root,"lib/firmware"));
35
+ expect(gpuReady(cfg)).toBe(false);
36
+ }finally{rmSync(root,{recursive:true,force:true});}
37
+ });
38
+ test("launcher documents no-gpu and rejects it without fetch/build",()=>{
39
+ const help=Bun.spawnSync([process.execPath,"index.js","--help"]);
40
+ expect(help.exitCode).toBe(0);expect(help.stdout.toString()).toContain("--no-gpu");
41
+ const invalid=Bun.spawnSync([process.execPath,"index.js","-r","--no-gpu"]);
42
+ expect(invalid.exitCode).not.toBe(0);expect(invalid.stderr.toString()).toContain("only make sense");
43
+ });
@@ -0,0 +1,29 @@
1
+ import { expect, test } from "bun:test";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ const entry = fileURLToPath(new URL("../index.js", import.meta.url));
5
+
6
+ for (const [flag, filename] of [
7
+ ["--readme", "README.md"],
8
+ ["--readme-zh", "README.zh.md"],
9
+ ["--docs", "README.zh.md"],
10
+ ]) {
11
+ test(`${flag} flushes the complete document to a pipe before exiting`, async () => {
12
+ const markdown = await Bun.file(new URL(`../${filename}`, import.meta.url)).text();
13
+ const rendered = Bun.markdown.ansi(markdown, { hyperlinks: true });
14
+ const expected = rendered.endsWith("\n") ? rendered : `${rendered}\n`;
15
+ // Both real documents exceed the pipe chunk that was previously truncated.
16
+ expect(Buffer.byteLength(expected)).toBeGreaterThan(65536);
17
+ const child = Bun.spawn([process.execPath, entry, flag], {
18
+ stdout: "pipe", stderr: "pipe",
19
+ });
20
+ const [output, errors, status] = await Promise.all([
21
+ new Response(child.stdout).text(),
22
+ new Response(child.stderr).text(),
23
+ child.exited,
24
+ ]);
25
+ expect(status).toBe(0);
26
+ expect(errors).toBe("");
27
+ expect(output).toBe(expected);
28
+ });
29
+ }