@celestia-island/hikari 0.55.247 → 0.55.249
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/package.json +5 -1
- package/src/components/HkWaterfall.scss +24 -3
- package/src/components/HkWaterfall.styles.test.ts +66 -0
- package/src/plugins/boot-recovery.d.mts +132 -0
- package/src/plugins/boot-recovery.install.test.ts +280 -0
- package/src/plugins/boot-recovery.mjs +685 -0
- package/src/plugins/boot-recovery.test.ts +970 -0
- package/src/plugins/index.ts +19 -0
- package/src/runtime/closeGuard.test.ts +279 -0
- package/src/runtime/closeGuard.ts +264 -0
- package/src/runtime/index.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celestia-island/hikari",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.249",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
|
|
@@ -41,6 +41,10 @@
|
|
|
41
41
|
"./plugins/boot-chrome": {
|
|
42
42
|
"import": "./src/plugins/boot-chrome.mjs",
|
|
43
43
|
"types": "./src/plugins/boot-chrome.d.mts"
|
|
44
|
+
},
|
|
45
|
+
"./plugins/boot-recovery": {
|
|
46
|
+
"import": "./src/plugins/boot-recovery.mjs",
|
|
47
|
+
"types": "./src/plugins/boot-recovery.d.mts"
|
|
44
48
|
}
|
|
45
49
|
},
|
|
46
50
|
"files": [
|
|
@@ -21,11 +21,19 @@
|
|
|
21
21
|
.hk-waterfall-columns {
|
|
22
22
|
display: flex;
|
|
23
23
|
align-items: flex-start;
|
|
24
|
-
gap: var(--space-12);
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
/* Independent columns (not CSS columns): each keeps its own transition
|
|
28
|
-
group, so a card arriving in one column never reflows the other.
|
|
27
|
+
group, so a card arriving in one column never reflows the other.
|
|
28
|
+
Column spacing rides SIBLING MARGINS, never a container `gap`: a gap is
|
|
29
|
+
spent between every adjacent pair of rendered flex items — including the
|
|
30
|
+
pair (live column, collapsed column) — so with a collapsed trailing
|
|
31
|
+
column the surviving card rendered one `--space-12` narrower than the
|
|
32
|
+
bucket while the bucket's other children (a full-width bucketHeader
|
|
33
|
+
caption row) kept the true right edge. Every single-card section drew a
|
|
34
|
+
ragged right edge against its own header (chest reports view, user
|
|
35
|
+
screenshot 2026-10-31). A sibling margin is only spent where two
|
|
36
|
+
width-carrying columns actually sit, and an empty column cancels it. */
|
|
29
37
|
.hk-waterfall-column {
|
|
30
38
|
flex: 1 1 0;
|
|
31
39
|
min-width: 0;
|
|
@@ -34,10 +42,23 @@
|
|
|
34
42
|
gap: var(--space-12);
|
|
35
43
|
}
|
|
36
44
|
|
|
45
|
+
.hk-waterfall-column + .hk-waterfall-column {
|
|
46
|
+
margin-inline-start: var(--space-12);
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
/* A bucket with fewer cards than columns leaves an empty column behind.
|
|
38
50
|
Without this it keeps its `flex: 1 1 0` share of the row and the
|
|
39
51
|
surviving card renders at half width beside a ghost; collapsed, the card
|
|
40
|
-
takes the full width until the bucket grows a card for that column
|
|
52
|
+
takes the full width until the bucket grows a card for that column —
|
|
53
|
+
and with the margin-based spacing above it costs no gap either, so the
|
|
54
|
+
surviving column spans the bucket edge to edge. */
|
|
41
55
|
.hk-waterfall-column[data-empty] {
|
|
42
56
|
flex: 0 0 0;
|
|
57
|
+
margin-inline-start: 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Same no-ghost-cost rule on the leading edge: an empty column ahead of a
|
|
61
|
+
live one must not indent it off the bucket's start edge either. */
|
|
62
|
+
.hk-waterfall-column[data-empty] + .hk-waterfall-column {
|
|
63
|
+
margin-inline-start: 0;
|
|
43
64
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { compile } from "sass";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiled-stylesheet guard for HkWaterfall's column geometry — the axis a
|
|
10
|
+
* runtime jsdom suite cannot see (it does no layout): how the two masonry
|
|
11
|
+
* columns share the bucket row.
|
|
12
|
+
*
|
|
13
|
+
* 1. column spacing is SIBLING MARGIN, not a container `gap`;
|
|
14
|
+
* 2. a collapsed (`[data-empty]`) column costs no width AND no spacing —
|
|
15
|
+
* a container gap would be spent on the (live, collapsed) pair, so the
|
|
16
|
+
* surviving card rendered one `--space-12` narrower than the bucket
|
|
17
|
+
* while full-width bucketHeader caption rows kept the true edge: a
|
|
18
|
+
* ragged right edge on every single-card section (chest reports view,
|
|
19
|
+
* user screenshot 2026-10-31);
|
|
20
|
+
* 3. a live column AFTER a collapsed one is not indented off the bucket's
|
|
21
|
+
* start edge either.
|
|
22
|
+
*
|
|
23
|
+
* Vitest stubs CSS, so the runtime suite can never catch a lost rule —
|
|
24
|
+
* this compiles the sheet directly and asserts the contract.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
28
|
+
|
|
29
|
+
const css = compile(resolve(componentDir, "HkWaterfall.scss"), {
|
|
30
|
+
style: "expanded",
|
|
31
|
+
}).css;
|
|
32
|
+
|
|
33
|
+
function ruleFor(selector: string): string {
|
|
34
|
+
const start = css.indexOf(selector);
|
|
35
|
+
expect(start, `${selector} present in the compiled sheet`).toBeGreaterThanOrEqual(0);
|
|
36
|
+
const open = css.indexOf("{", start);
|
|
37
|
+
const close = css.indexOf("}", open);
|
|
38
|
+
return css.slice(start, close + 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("HkWaterfall column spacing contract", () => {
|
|
42
|
+
it("compiles without warnings", () => {
|
|
43
|
+
expect(css).toContain(".hk-waterfall-columns");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("the columns row carries no container gap (a gap would be spent on collapsed columns too)", () => {
|
|
47
|
+
const rule = ruleFor(".hk-waterfall-columns {");
|
|
48
|
+
expect(rule).not.toContain("gap");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("adjacent live columns are spaced by a sibling margin", () => {
|
|
52
|
+
const rule = ruleFor(".hk-waterfall-column + .hk-waterfall-column {");
|
|
53
|
+
expect(rule).toContain("margin-inline-start: var(--space-12)");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("a collapsed column costs no width and no spacing", () => {
|
|
57
|
+
const rule = ruleFor(".hk-waterfall-column[data-empty] {");
|
|
58
|
+
expect(rule).toContain("flex: 0 0 0");
|
|
59
|
+
expect(rule).toContain("margin-inline-start: 0");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("a live column after a collapsed one starts flush at the bucket edge", () => {
|
|
63
|
+
const rule = ruleFor(".hk-waterfall-column[data-empty] + .hk-waterfall-column {");
|
|
64
|
+
expect(rule).toContain("margin-inline-start: 0");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// AI-generated: developed with the DeepSeek Harness agent (model
|
|
2
|
+
// DeepSeek-Flash) and the ZCode agent (model GLM-5.3), directed and reviewed
|
|
3
|
+
// by langyo — SySL-1.0 §2.3 disclosure.
|
|
4
|
+
|
|
5
|
+
import type { Plugin } from "vite";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Types for `boot-recovery.mjs` — the self-contained, published Vite plugin
|
|
9
|
+
* entry (`@celestia-island/hikari/plugins/boot-recovery`).
|
|
10
|
+
*
|
|
11
|
+
* The implementation is plain ESM loaded from `node_modules` while a consumer's
|
|
12
|
+
* `vite.config.*` is resolved, where TypeScript cannot be stripped and Vite's
|
|
13
|
+
* `configLoader: "runner"` tears the module runner down before plugin hooks
|
|
14
|
+
* run. Keep the runtime file limited to static imports (`node:path` only).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The canonical Tauri app origin on Windows/Android. */
|
|
18
|
+
export declare const BOOT_RECOVERY_CANONICAL_ORIGIN: "http://tauri.localhost";
|
|
19
|
+
|
|
20
|
+
/** Default watchdog delay in ms. */
|
|
21
|
+
export declare const BOOT_RECOVERY_DEADLINE_MS: 15000;
|
|
22
|
+
|
|
23
|
+
/** Default selector of the host's static loading element. */
|
|
24
|
+
export declare const BOOT_RECOVERY_LOADING_SELECTOR: "#loading-screen";
|
|
25
|
+
|
|
26
|
+
/** Default class that marks a loading element as already dismissed. */
|
|
27
|
+
export declare const BOOT_RECOVERY_DISMISSED_CLASS: "hidden";
|
|
28
|
+
|
|
29
|
+
/** Default failure-panel message (English; override through `copy.message`). */
|
|
30
|
+
export declare const BOOT_RECOVERY_MESSAGE: string;
|
|
31
|
+
|
|
32
|
+
/** Default failure-panel retry label (English; override through `copy.retry`). */
|
|
33
|
+
export declare const BOOT_RECOVERY_RETRY: string;
|
|
34
|
+
|
|
35
|
+
/** Marker attribute on the injected inline `<script>` tag. */
|
|
36
|
+
export declare const BOOT_RECOVERY_ATTR: "data-hk-boot-recovery";
|
|
37
|
+
|
|
38
|
+
/** The default options, frozen (see {@link BootRecoveryPluginOptions}). */
|
|
39
|
+
export interface BootRecoveryDefaults {
|
|
40
|
+
readonly canonicalOrigin: string;
|
|
41
|
+
readonly deadlineMs: number;
|
|
42
|
+
readonly loadingScreen: Readonly<{
|
|
43
|
+
selector: string;
|
|
44
|
+
dismissedClass: string;
|
|
45
|
+
}>;
|
|
46
|
+
readonly copy: Readonly<{ message: string; retry: string }>;
|
|
47
|
+
readonly injectTo: "head" | "head-prepend";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Frozen snapshot of every default. */
|
|
51
|
+
export declare const BOOT_RECOVERY_DEFAULTS: Readonly<BootRecoveryDefaults>;
|
|
52
|
+
|
|
53
|
+
/** The loading-screen half of the recovery (the watchdog). */
|
|
54
|
+
export interface BootRecoveryLoadingScreenOptions {
|
|
55
|
+
/** Element whose contents are replaced when nothing mounted (default `#loading-screen`). */
|
|
56
|
+
selector?: string;
|
|
57
|
+
/** Class marking the element as dismissed/fading (default `hidden`; empty disables the check). */
|
|
58
|
+
dismissedClass?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Overridable failure-panel copy (English defaults). */
|
|
62
|
+
export interface BootRecoveryCopyOptions {
|
|
63
|
+
/** Main message of the panel. */
|
|
64
|
+
message?: string;
|
|
65
|
+
/** Retry button label. */
|
|
66
|
+
retry?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Options for {@link bootRecoveryPlugin} / {@link bootRecoveryScript}. */
|
|
70
|
+
export interface BootRecoveryPluginOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Which HTML entries get the inline script. Default: only entries whose
|
|
73
|
+
* basename is `index.html`. Multi-window shells MUST configure this — a
|
|
74
|
+
* transparent overlay window must never be reloaded or healed by boot
|
|
75
|
+
* recovery.
|
|
76
|
+
*
|
|
77
|
+
* A `RegExp` is matched against the entry's file path; a `string[]` is
|
|
78
|
+
* matched against the basename or the path suffix (`"pages/index.html"`).
|
|
79
|
+
*/
|
|
80
|
+
htmlFiles?: string[] | RegExp;
|
|
81
|
+
/**
|
|
82
|
+
* The watchdog half: an object customises the loading element; `false`
|
|
83
|
+
* disables it entirely (heal-only mode, for hosts with their own fatal
|
|
84
|
+
* landing page). Default: `{ selector: "#loading-screen", dismissedClass: "hidden" }`.
|
|
85
|
+
*/
|
|
86
|
+
loadingScreen?: BootRecoveryLoadingScreenOptions | false;
|
|
87
|
+
/** Watchdog delay in ms (default 15000). */
|
|
88
|
+
deadlineMs?: number;
|
|
89
|
+
/** Absolute http(s) app origin (default `http://tauri.localhost`). */
|
|
90
|
+
canonicalOrigin?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Custom mangled-origin predicate. It runs in the PAGE, so it is embedded
|
|
93
|
+
* by source text: pass a self-contained ES2017-safe function expression or
|
|
94
|
+
* arrow function (no closures, no method shorthand, ASCII-only). Default:
|
|
95
|
+
* `isMangledOrigin` (http/https only, hostname starts with the canonical
|
|
96
|
+
* host and differs from it; `tauri://` untouched).
|
|
97
|
+
*/
|
|
98
|
+
isMangledOrigin?: (protocol: string, hostname: string) => boolean;
|
|
99
|
+
/** Failure-panel copy overrides. */
|
|
100
|
+
copy?: BootRecoveryCopyOptions;
|
|
101
|
+
/**
|
|
102
|
+
* Where the tag is injected. Default `"head"` (end of `<head>`, after
|
|
103
|
+
* `<meta charset>` — charset-prescan safe). `"head-prepend"` runs even
|
|
104
|
+
* earlier but is charset-unsafe (the encoding prescan only reads the first
|
|
105
|
+
* 1024 bytes); use it only when the host sends an explicit charset header.
|
|
106
|
+
*/
|
|
107
|
+
injectTo?: "head" | "head-prepend";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The default mangled-origin predicate: http(s) only, a hostname that starts
|
|
112
|
+
* with the canonical origin's hostname but is not equal to it (`tauri://`
|
|
113
|
+
* origins are never redirected).
|
|
114
|
+
*/
|
|
115
|
+
export declare function isMangledOrigin(
|
|
116
|
+
protocol: string,
|
|
117
|
+
hostname: string,
|
|
118
|
+
canonicalOrigin?: string,
|
|
119
|
+
): boolean;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The generated inline IIFE — deterministic, ASCII-only, classic (non-module)
|
|
123
|
+
* and safe to hash into a host CSP.
|
|
124
|
+
*/
|
|
125
|
+
export declare function bootRecoveryScript(
|
|
126
|
+
options?: BootRecoveryPluginOptions,
|
|
127
|
+
): string;
|
|
128
|
+
|
|
129
|
+
/** Vite plugin: inject the boot recovery script into the configured entries. */
|
|
130
|
+
export declare function bootRecoveryPlugin(
|
|
131
|
+
options?: BootRecoveryPluginOptions,
|
|
132
|
+
): Plugin;
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
// AI-generated: developed with the DeepSeek Harness agent (model
|
|
3
|
+
// DeepSeek-Flash) and the ZCode agent (model GLM-5.3), directed and reviewed
|
|
4
|
+
// by langyo — SySL-1.0 §2.3 disclosure.
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import {
|
|
8
|
+
cpSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The npm install-face regression test for `boot-recovery.mjs`.
|
|
25
|
+
*
|
|
26
|
+
* `bootRecoveryPlugin` runs inside a consumer's `vite.config.*`, loaded from
|
|
27
|
+
* `node_modules`. Two failure modes have bitten hikari before (pinned by
|
|
28
|
+
* `boot-chrome.install.test.ts`, reproduced here for this entry):
|
|
29
|
+
*
|
|
30
|
+
* 1. shipping the plugin as TypeScript → Node refuses type stripping under
|
|
31
|
+
* `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`);
|
|
32
|
+
* 2. a dynamic `import(...)` from a plugin hook → Vite's
|
|
33
|
+
* `configLoader: "runner"` has already torn the module runner down
|
|
34
|
+
* ("Vite module runner has been closed").
|
|
35
|
+
*
|
|
36
|
+
* Vitest transforms TS and keeps a module runner alive, so it can never catch
|
|
37
|
+
* either one. This test copies the published layout into a fake
|
|
38
|
+
* `node_modules` — `src/plugins` ONLY: boot recovery needs no `src/res`
|
|
39
|
+
* asset, which is part of its install face — and imports it from a CHILD
|
|
40
|
+
* `node` process, then builds a consumer through Vite with BOTH config loaders.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
const VUE_DIR = resolve(HERE, "../..");
|
|
45
|
+
const PACKAGE_JSON = JSON.parse(
|
|
46
|
+
readFileSync(join(VUE_DIR, "package.json"), "utf8"),
|
|
47
|
+
) as {
|
|
48
|
+
name: string;
|
|
49
|
+
version: string;
|
|
50
|
+
type: string;
|
|
51
|
+
exports: Record<string, { import: string; types?: string }>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const INSTALL_SUBPATH = "./plugins/boot-recovery";
|
|
55
|
+
|
|
56
|
+
const temps: string[] = [];
|
|
57
|
+
|
|
58
|
+
afterAll(() => {
|
|
59
|
+
for (const dir of temps.splice(0)) {
|
|
60
|
+
try {
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
} catch {
|
|
63
|
+
/* a leftover temp dir must not fail the suite */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** Lay out `node_modules/@celestia-island/hikari` exactly like `npm pack` does. */
|
|
69
|
+
function fakeInstall(): string {
|
|
70
|
+
const root = mkdtempSync(join(tmpdir(), "hikari-boot-recovery-install-"));
|
|
71
|
+
temps.push(root);
|
|
72
|
+
|
|
73
|
+
const pkgDir = join(root, "node_modules", PACKAGE_JSON.name);
|
|
74
|
+
mkdirSync(join(pkgDir, "src"), { recursive: true });
|
|
75
|
+
// `files: ["src/"]` — the published tarball carries the sources it needs.
|
|
76
|
+
// NOTE: no `src/res` copy on purpose — this plugin has no runtime asset.
|
|
77
|
+
cpSync(join(VUE_DIR, "src/plugins"), join(pkgDir, "src/plugins"), {
|
|
78
|
+
recursive: true,
|
|
79
|
+
});
|
|
80
|
+
writeFileSync(
|
|
81
|
+
join(pkgDir, "package.json"),
|
|
82
|
+
JSON.stringify(
|
|
83
|
+
{
|
|
84
|
+
name: PACKAGE_JSON.name,
|
|
85
|
+
version: PACKAGE_JSON.version,
|
|
86
|
+
private: false,
|
|
87
|
+
type: PACKAGE_JSON.type,
|
|
88
|
+
exports: PACKAGE_JSON.exports,
|
|
89
|
+
},
|
|
90
|
+
null,
|
|
91
|
+
2,
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
writeFileSync(
|
|
95
|
+
join(root, "package.json"),
|
|
96
|
+
JSON.stringify(
|
|
97
|
+
{ name: "boot-recovery-install-probe", private: true, type: "module" },
|
|
98
|
+
null,
|
|
99
|
+
2,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
return root;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Run any command in a child process, capturing everything. */
|
|
106
|
+
function runCommand(
|
|
107
|
+
command: string,
|
|
108
|
+
args: string[],
|
|
109
|
+
cwd: string,
|
|
110
|
+
): { code: number; stdout: string; stderr: string } {
|
|
111
|
+
try {
|
|
112
|
+
const stdout = execFileSync(command, args, {
|
|
113
|
+
cwd,
|
|
114
|
+
encoding: "utf8",
|
|
115
|
+
stdio: "pipe",
|
|
116
|
+
timeout: 120_000,
|
|
117
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
118
|
+
});
|
|
119
|
+
return { code: 0, stdout, stderr: "" };
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const failure = err as { status?: number; stdout?: string; stderr?: string };
|
|
122
|
+
return {
|
|
123
|
+
code: failure.status ?? 1,
|
|
124
|
+
stdout: failure.stdout ?? "",
|
|
125
|
+
stderr: failure.stderr ?? String(err),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Run one ESM probe in a child `node` (no bundler, no transforms). */
|
|
131
|
+
function runProbe(
|
|
132
|
+
root: string,
|
|
133
|
+
source: string,
|
|
134
|
+
): { code: number; stdout: string; stderr: string } {
|
|
135
|
+
try {
|
|
136
|
+
const stdout = execFileSync(
|
|
137
|
+
process.execPath,
|
|
138
|
+
["--input-type=module", "-e", source],
|
|
139
|
+
{ cwd: root, encoding: "utf8", stdio: "pipe", timeout: 30_000 },
|
|
140
|
+
);
|
|
141
|
+
return { code: 0, stdout, stderr: "" };
|
|
142
|
+
} catch (err) {
|
|
143
|
+
const failure = err as { status?: number; stdout?: string; stderr?: string };
|
|
144
|
+
return {
|
|
145
|
+
code: failure.status ?? 1,
|
|
146
|
+
stdout: failure.stdout ?? "",
|
|
147
|
+
stderr: failure.stderr ?? String(err),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
describe("published install face (boot recovery)", () => {
|
|
153
|
+
it("declares a resolvable ESM subpath with types", () => {
|
|
154
|
+
const entry = PACKAGE_JSON.exports[INSTALL_SUBPATH];
|
|
155
|
+
expect(entry, INSTALL_SUBPATH + " must be exported").toBeTruthy();
|
|
156
|
+
expect(entry.import).toBe("./src/plugins/boot-recovery.mjs");
|
|
157
|
+
expect(entry.types).toBe("./src/plugins/boot-recovery.d.mts");
|
|
158
|
+
expect(existsSync(join(VUE_DIR, entry.import))).toBe(true);
|
|
159
|
+
expect(existsSync(join(VUE_DIR, entry.types!))).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("imports and runs from node_modules in a plain child node process", { timeout: 60_000 }, () => {
|
|
163
|
+
const root = fakeInstall();
|
|
164
|
+
const probe = [
|
|
165
|
+
"// Plain Node: no type stripping of node_modules, no Vite module runner.",
|
|
166
|
+
'const mod = await import("' + PACKAGE_JSON.name + '/plugins/boot-recovery");',
|
|
167
|
+
"const script = mod.bootRecoveryScript();",
|
|
168
|
+
'if (!script.includes("http://tauri.localhost")) {',
|
|
169
|
+
' console.error("NO_SCRIPT");',
|
|
170
|
+
" process.exit(3);",
|
|
171
|
+
"}",
|
|
172
|
+
'if (!script.startsWith("(function () {")) {',
|
|
173
|
+
' console.error("NOT_AN_IIFE");',
|
|
174
|
+
" process.exit(4);",
|
|
175
|
+
"}",
|
|
176
|
+
"const plugin = mod.bootRecoveryPlugin();",
|
|
177
|
+
'if (!plugin || plugin.name !== "hikari-boot-recovery") {',
|
|
178
|
+
' console.error("NO_FACTORY");',
|
|
179
|
+
" process.exit(5);",
|
|
180
|
+
"}",
|
|
181
|
+
'const tags = plugin.transformIndexHtml("", { filename: "/app/index.html" });',
|
|
182
|
+
"if (",
|
|
183
|
+
" !Array.isArray(tags) ||",
|
|
184
|
+
" tags.length !== 1 ||",
|
|
185
|
+
' tags[0].tag !== "script" ||',
|
|
186
|
+
' tags[0].injectTo !== "head" ||',
|
|
187
|
+
' tags[0].attrs["data-hk-boot-recovery"] !== "" ||',
|
|
188
|
+
" tags[0].children !== script",
|
|
189
|
+
") {",
|
|
190
|
+
' console.error("NO_TAG");',
|
|
191
|
+
" process.exit(6);",
|
|
192
|
+
"}",
|
|
193
|
+
"const healOnly = mod.bootRecoveryScript({ loadingScreen: false });",
|
|
194
|
+
'if (healOnly.includes("setTimeout(")) {',
|
|
195
|
+
' console.error("HEAL_ONLY_BROKEN");',
|
|
196
|
+
" process.exit(7);",
|
|
197
|
+
"}",
|
|
198
|
+
'console.log("INSTALL_OK " + script.length);',
|
|
199
|
+
].join("\n");
|
|
200
|
+
const result = runProbe(root, probe);
|
|
201
|
+
|
|
202
|
+
expect(
|
|
203
|
+
result.stderr,
|
|
204
|
+
"child node stderr (type-strip / runner failures show up here):\n" +
|
|
205
|
+
result.stderr,
|
|
206
|
+
).not.toMatch(
|
|
207
|
+
/ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING|module runner has been closed|Cannot find module/,
|
|
208
|
+
);
|
|
209
|
+
expect(result.code, "child exited " + result.code + ": " + result.stderr).toBe(0);
|
|
210
|
+
expect(result.stdout).toContain("INSTALL_OK");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("builds a consumer through vite with BOTH config loaders", { timeout: 120_000 }, () => {
|
|
214
|
+
// The R2 scenario: a consumer whose vite.config imports the plugin from its
|
|
215
|
+
// own node_modules. The DEFAULT loader fails on a TypeScript entry; the
|
|
216
|
+
// `runner` loader additionally fails on a dynamic import from a hook. Both
|
|
217
|
+
// loader modes are exercised so a future Vite bump gets caught.
|
|
218
|
+
const root = fakeInstall();
|
|
219
|
+
writeFileSync(
|
|
220
|
+
join(root, "index.html"),
|
|
221
|
+
'<!doctype html><html><head><meta charset="UTF-8"><title>App</title></head>' +
|
|
222
|
+
'<body><div id="loading-screen"><span>Loading...</span></div>' +
|
|
223
|
+
'<div id="app"></div><script type="module" src="/main.js"></script></body></html>',
|
|
224
|
+
);
|
|
225
|
+
writeFileSync(join(root, "main.js"), 'document.getElementById("app");');
|
|
226
|
+
writeFileSync(
|
|
227
|
+
join(root, "vite.config.mjs"),
|
|
228
|
+
[
|
|
229
|
+
'import { bootRecoveryPlugin } from "' +
|
|
230
|
+
PACKAGE_JSON.name +
|
|
231
|
+
'/plugins/boot-recovery";',
|
|
232
|
+
"export default {",
|
|
233
|
+
" plugins: [bootRecoveryPlugin()],",
|
|
234
|
+
' build: { outDir: "dist", emptyOutDir: true },',
|
|
235
|
+
"};",
|
|
236
|
+
].join("\n"),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Resolve the repo's own vite binary: the fake install has no toolchain,
|
|
240
|
+
// and the point is what a consumer's build does with the published files.
|
|
241
|
+
const viteEntry = createRequire(import.meta.url).resolve("vite");
|
|
242
|
+
const viteBin = resolve(dirname(viteEntry), "../../bin/vite.js");
|
|
243
|
+
for (const loader of ["default", "runner"]) {
|
|
244
|
+
const args = [viteBin, "build"];
|
|
245
|
+
if (loader === "runner") args.push("--configLoader", "runner");
|
|
246
|
+
const result = runCommand(process.execPath, args, root);
|
|
247
|
+
expect(
|
|
248
|
+
result.code,
|
|
249
|
+
"vite build (" + loader + " config loader) failed:\n" +
|
|
250
|
+
(result.stderr || result.stdout),
|
|
251
|
+
).toBe(0);
|
|
252
|
+
const html = readFileSync(join(root, "dist", "index.html"), "utf8");
|
|
253
|
+
expect(html).toContain("data-hk-boot-recovery");
|
|
254
|
+
expect(html).toContain("(function () {");
|
|
255
|
+
// Inline, not an external asset the install would have had to ship.
|
|
256
|
+
expect(html).not.toMatch(/<script[^>]*data-hk-boot-recovery[^>]*\ssrc=/);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("keeps the runtime module free of dynamic imports and TypeScript", () => {
|
|
261
|
+
// Enforce the properties the failure modes above come from: a refactor back
|
|
262
|
+
// to a dynamic import or a .ts implementation is caught without a full
|
|
263
|
+
// build. Comments mention the banned forms (they explain why), so only
|
|
264
|
+
// real code is checked.
|
|
265
|
+
const source = readFileSync(
|
|
266
|
+
join(VUE_DIR, "src/plugins/boot-recovery.mjs"),
|
|
267
|
+
"utf8",
|
|
268
|
+
);
|
|
269
|
+
const code = source
|
|
270
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
271
|
+
.replace(/^\s*\/\/.*$/gm, "");
|
|
272
|
+
expect(code).not.toMatch(/\bimport\s*\(/);
|
|
273
|
+
expect(code).not.toMatch(/\bfrom\s+["'][^"']*\.ts["']/);
|
|
274
|
+
// Static imports only — and only the one Node builtin it needs.
|
|
275
|
+
expect(code).toMatch(/from "node:path"/);
|
|
276
|
+
// No runtime asset: the plugin is pure string generation.
|
|
277
|
+
expect(code).not.toMatch(/res\//);
|
|
278
|
+
expect(code).not.toContain("readFileSync");
|
|
279
|
+
});
|
|
280
|
+
});
|