Compare commits

..
2 Commits
Author SHA1 Message Date
ddidderr dc326a0e3f fix(call-to-play): confirm early starts inline
Starting an underfilled or scheduled call immediately sent Start. Ask "Start
this call early?" inline, with Start now and Keep waiting choices, before
publishing an early start. Ready and elapsed calls retain one-click starting,
and local launch still waits for core acceptance.

Match the existing cancel confirmation layout, reset pending confirmation on
call or actor changes, and test the real action callbacks for dismissal,
retry, deferred acceptance, normal starts, and missing local games.

Test Plan:
- `just frontend-test` -- passed, 113 tests
- `just test` -- passed on rerun, 797 tests; the initial run hit an unchanged
  scoped-process test's two-second child-start timeout
- `just clippy` -- passed with warnings denied
- `just fmt` -- passed
- `deno task build` -- passed TypeScript and Vite production build
- `git diff --cached --check` -- passed
2026-09-15 23:41:43 +02:00
ddidderr 37899da18a fix(call-to-play): let callers start before the roster fills
The card hid Start now until the roster filled or the deadline elapsed,
preventing groups from starting early with fewer players. Expose the existing
start action for every unfinished caller-owned call, including scheduled calls
before check-in. Keep check-in controls and launch only after core acceptance.

Add rendered-card regressions for six-of-eight players, solo and scheduled
starts, creator authority, terminal states, and unavailable local games. Use
installed React packages for these tests without rewriting the app lockfile,
and document the caller's control over start time.

Test Plan:
- `just frontend-test` -- passed, 107 tests including 8 rendered-card tests
- `just test` -- passed, 797 Rust tests
- `just clippy` -- passed with warnings denied
- `just fmt` -- passed
- `deno task build` -- passed TypeScript and Vite production build
- `git diff --cached --check` -- passed
2026-09-15 23:27:47 +02:00
7 changed files with 418 additions and 43 deletions

No files matched your search

+5 -1
View File
@@ -91,6 +91,10 @@ just test
just frontend-test just frontend-test
``` ```
Frontend tests render components with the React packages installed by
`just setup`. Their separate Deno configuration uses those installed packages
without rewriting the application's dependency lockfile.
See [launcher shutdown ownership](crates/lanspread-tauri-deno-ts/SHUTDOWN.md) See [launcher shutdown ownership](crates/lanspread-tauri-deno-ts/SHUTDOWN.md)
for the cancellation/drain sequence and a native close-button regression probe. for the cancellation/drain sequence and a native close-button regression probe.
@@ -319,7 +323,7 @@ just peer-cli-charlie
| `just fmt` | Format Rust, TOML, Markdown, and the Justfile. | | `just fmt` | Format Rust, TOML, Markdown, and the Justfile. |
| `just clippy` | Lint every Rust workspace target with warnings denied. | | `just clippy` | Lint every Rust workspace target with warnings denied. |
| `just test` | Run every Rust workspace test target. | | `just test` | Run every Rust workspace test target. |
| `just frontend-test` | Run the frontend reducer/lifecycle tests. | | `just frontend-test` | Run frontend reducer, lifecycle, and rendered component tests. |
| `just peer-cli-build` | Build the JSONL peer harness. | | `just peer-cli-build` | Build the JSONL peer harness. |
| `just peer-cli-image` | Check fixture authority and build the harness Docker image. | | `just peer-cli-image` | Check fixture authority and build the harness Docker image. |
| `just peer-cli-tests [SCENARIO ...]` | Run selected scenarios, or the complete matrix when no IDs are supplied. | | `just peer-cli-tests [SCENARIO ...]` | Run selected scenarios, or the complete matrix when no IDs are supplied. |
@@ -0,0 +1,9 @@
{
"nodeModulesDir": "manual",
"lock": false,
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"lib": ["deno.window", "dom", "dom.iterable"]
}
}
@@ -70,10 +70,14 @@ export const NominationCard = ({
onLaunch, onLaunch,
}: Props) => { }: Props) => {
const [confirmCancel, setConfirmCancel] = useState(false); const [confirmCancel, setConfirmCancel] = useState(false);
const [confirmStart, setConfirmStart] = useState(false);
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (focused) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' }); if (focused) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, [focused]); }, [focused]);
useEffect(() => {
setConfirmStart(false);
}, [nomination.id, nomination.state, actorId]);
const now = Date.now(); const now = Date.now();
const entries = Object.entries(nomination.participants); const entries = Object.entries(nomination.participants);
@@ -212,6 +216,8 @@ export const NominationCard = ({
actions={actions} actions={actions}
onLaunch={onLaunch} onLaunch={onLaunch}
now={now} now={now}
confirmStart={confirmStart}
onStartConfirmationChange={setConfirmStart}
/> />
</div> </div>
@@ -255,6 +261,8 @@ interface CardActionsProps {
actions: CallToPlayActions; actions: CallToPlayActions;
onLaunch: (game: Game) => void; onLaunch: (game: Game) => void;
now: number; now: number;
confirmStart: boolean;
onStartConfirmationChange: (confirm: boolean) => void;
} }
const ReadyButtons = ({ nomination, actions, includeThirty = false }: { const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
@@ -278,13 +286,15 @@ const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
</> </>
); );
const CardActions = ({ export const CardActions = ({
nomination, nomination,
game, game,
actorId, actorId,
actions, actions,
onLaunch, onLaunch,
now, now,
confirmStart,
onStartConfirmationChange,
}: CardActionsProps) => { }: CardActionsProps) => {
const myStatus = actorId === null ? undefined : nomination.participants[actorId]; const myStatus = actorId === null ? undefined : nomination.participants[actorId];
const isMe = myStatus !== undefined; const isMe = myStatus !== undefined;
@@ -294,6 +304,11 @@ const CardActions = ({
const isExpired = statusOf(nomination, now) === 'expired'; const isExpired = statusOf(nomination, now) === 'expired';
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !terminal; const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !terminal;
const readyCount = readyCountOf(nomination, now); const readyCount = readyCountOf(nomination, now);
const startCall = async () => {
onStartConfirmationChange(false);
const accepted = await actions.startNow(nomination.id);
if (accepted && game) onLaunch(game);
};
if (terminal) { if (terminal) {
return ( return (
@@ -306,14 +321,57 @@ const CardActions = ({
</div> </div>
); );
} }
// Readiness and the schedule guide coordination; the caller may start early.
if (isCreator) {
return (
<>
{confirmStart && !isDone
? (
<div className="ctp-start-confirm" role="group" aria-label="Start this call early?">
<span>Start this call early?</span>
<div className="ctp-start-confirm-btns">
<button className="act-btn act-play" onClick={startCall}>
<Icon.play /><span>Start now</span>
</button>
<button
className="ghost-btn"
onClick={() => onStartConfirmationChange(false)}
>Keep waiting</button>
</div>
</div>
)
: (
<button
className="act-btn act-play"
onClick={() => {
if (!isDone) {
onStartConfirmationChange(true);
return;
}
return startCall();
}}
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
)}
{isDone && (
<button
className="ghost-btn"
onClick={() => actions.addTime(nomination.id, nomination.deadline)}
>
Add 5 more minutes
</button>
)}
{scheduled && (
<div className="ctp-note">
Scheduled for {formatClock(nomination.scheduledFor!)} — check-in opens 15 min before start.
</div>
)}
{!isDone && !scheduled && myStatus?.status === 'in' && (
<ReadyButtons nomination={nomination} actions={actions} />
)}
</>
);
}
if (scheduled) { if (scheduled) {
if (isCreator) {
return (
<div className="ctp-note">
Scheduled for {formatClock(nomination.scheduledFor!)} — check-in opens 15 min before start.
</div>
);
}
if (isMe) { if (isMe) {
return ( return (
<> <>
@@ -333,30 +391,6 @@ const CardActions = ({
</> </>
); );
} }
if (isCreator) {
if (isDone) {
return (
<>
<button
className="act-btn act-play"
onClick={() => void actions.startNow(nomination.id).then(accepted => {
if (accepted && game) onLaunch(game);
})}
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
<button
className="ghost-btn"
onClick={() => actions.addTime(nomination.id, nomination.deadline)}
>
Add 5 more minutes
</button>
</>
);
}
if (myStatus?.status === 'in') {
return <ReadyButtons nomination={nomination} actions={actions} />;
}
return <div className="ctp-note">Waiting for players to ready up…</div>;
}
if (isDone) { if (isDone) {
return ( return (
<div className="ctp-note"> <div className="ctp-note">
@@ -2201,26 +2201,38 @@
.ctp-cancel-link:hover { .ctp-cancel-link:hover {
color: #fca5a5; color: #fca5a5;
} }
.ctp-cancel-confirm { .ctp-cancel-confirm,
.ctp-start-confirm {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
background: rgba(239,68,68,0.08);
border: 1px solid rgba(239,68,68,0.35);
border-radius: 8px; border-radius: 8px;
font-size: 12.5px; font-size: 12.5px;
color: #fca5a5;
font-weight: 600; font-weight: 600;
} }
.ctp-cancel-confirm-btns { .ctp-cancel-confirm {
background: rgba(239,68,68,0.08);
border: 1px solid rgba(239,68,68,0.35);
color: #fca5a5;
}
.ctp-start-confirm {
flex: 1 1 100%;
background: color-mix(in srgb, var(--accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
color: var(--t-1);
}
.ctp-cancel-confirm-btns,
.ctp-start-confirm-btns {
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
flex-shrink: 0; flex-shrink: 0;
} }
.ctp-cancel-confirm-btns .ghost-btn { .ctp-cancel-confirm-btns .ghost-btn,
.ctp-actions .ctp-start-confirm-btns .act-btn,
.ctp-actions .ctp-start-confirm-btns .ghost-btn {
height: 32px; height: 32px;
padding: 0 12px; padding: 0 12px;
font-size: 12px; font-size: 12px;
@@ -0,0 +1,304 @@
import { Children, createElement, isValidElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { CardActions, NominationCard } from '../src/components/calltoplay/NominationCard.tsx';
import { type CallToPlayActions } from '../src/hooks/useCallToPlay.ts';
import { reduceCallToPlayEvents } from '../src/lib/callToPlay.ts';
import {
type CallToPlayViewEvent,
type Game,
GameAvailability,
InstallStatus,
type Nomination,
} from '../src/lib/types.ts';
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
const assertEquals = <T>(actual: T, expected: T, message: string): void => {
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
};
const game: Game = {
id: 'game-1',
name: 'Test game',
description: '',
size: 0,
downloaded: true,
installed: true,
availability: GameAvailability.Ready,
install_status: InstallStatus.Installed,
peer_count: 0,
};
const unexpectedAction = (): never => {
throw new Error('Rendering a card must not publish an action or launch a game');
};
const actions: CallToPlayActions = {
createNomination: unexpectedAction,
respond: unexpectedAction,
rsvp: unexpectedAction,
sendMessage: unexpectedAction,
leave: unexpectedAction,
cancel: unexpectedAction,
startNow: unexpectedAction,
addTime: unexpectedAction,
};
const call = (
now: number,
players = 1,
scheduledFor: number | null = null,
deadline = scheduledFor ?? now + 10 * 60_000,
): Nomination => {
const events: CallToPlayViewEvent[] = [{
id: 'create',
call_id: 'call-1',
author_id: 'Alice',
author_name: 'Alice',
at: now - 60_000,
action: {
Create: {
game_id: game.id,
max_players: 8,
scheduled_for: scheduledFor,
deadline,
},
},
}];
for (let index = 1; index < players; index += 1) {
events.push({
id: `join-${index}`,
call_id: 'call-1',
author_id: `Player-${index}`,
author_name: `Player ${index}`,
at: now - 30_000 + index,
action: scheduledFor === null ? { Respond: { ready_at: null } } : 'Rsvp',
});
}
const [nomination] = reduceCallToPlayEvents(events, now);
assert(nomination, 'the call must be visible');
return nomination;
};
const renderCard = (
nomination: Nomination,
actorId: string | null = 'Alice',
localGame: Game | null = game,
): string => renderToStaticMarkup(createElement(NominationCard, {
nomination,
game: localGame,
actorId,
actions,
focused: false,
totalPeerCount: 5,
onLaunch: unexpectedAction,
}));
const buttonNamed = (markup: string, label: string): string | undefined =>
markup.match(/<button\b[^>]*>.*?<\/button>/gs)?.find(button =>
button.replace(/<[^>]*>/g, '').trim() === label
);
const assertStartAvailable = (markup: string, label = 'Start now'): void => {
const button = buttonNamed(markup, label);
assert(button !== undefined, `${label} must be rendered`);
assert(!/\bdisabled\b/.test(button), `${label} must be enabled`);
};
Deno.test('caller can start an eight-player call early with six ready players', () => {
const nomination = call(Date.now(), 6);
assert(nomination.state === 'open', 'six players must not fill an eight-player call');
assertStartAvailable(renderCard(nomination));
});
Deno.test('caller can start alone before the call deadline', () => {
assertStartAvailable(renderCard(call(Date.now())));
});
Deno.test('caller can start a scheduled call before check-in opens', () => {
const now = Date.now();
const markup = renderCard(call(now, 1, now + 60 * 60_000));
assertStartAvailable(markup);
assert(markup.includes('check-in opens 15 min before start'), 'schedule guidance remains');
});
Deno.test('caller can start during check-in and can still mark themselves ready', () => {
const now = Date.now();
const markup = renderCard(call(now, 1, now + 10 * 60_000));
assertStartAvailable(markup);
assert(buttonNamed(markup, 'Ready now'), 'caller check-in controls remain');
});
Deno.test('full and elapsed calls keep start and extension controls', () => {
const now = Date.now();
for (const nomination of [call(now, 8), call(now, 1, null, now - 1_000)]) {
const markup = renderCard(nomination);
assertStartAvailable(markup);
assert(buttonNamed(markup, 'Add 5 more minutes'), 'extension remains available');
}
});
Deno.test('participants and outsiders cannot start a call in any active phase', () => {
const now = Date.now();
const nominations = [
call(now, 6),
call(now, 6, now + 60 * 60_000),
call(now, 6, now + 10 * 60_000),
call(now, 8),
call(now, 6, null, now - 1_000),
];
for (const nomination of nominations) {
for (const actorId of ['Player-1', 'Outsider', null]) {
const markup = renderCard(nomination, actorId);
assert(!buttonNamed(markup, 'Start now'), 'only the caller can start');
assert(!buttonNamed(markup, 'Add 5 more minutes'), 'only the caller can extend');
}
}
});
Deno.test('running and cancelled calls cannot be started again', () => {
const now = Date.now();
for (const state of ['running', 'cancelled'] as const) {
const nomination = { ...call(now, 6), state, terminalAt: now };
for (const actorId of ['Alice', 'Player-1']) {
const markup = renderCard(nomination, actorId);
assert(!buttonNamed(markup, 'Start now'), 'terminal calls cannot restart');
assert(!buttonNamed(markup, 'Add 5 more minutes'), 'terminal calls cannot extend');
assert(!buttonNamed(markup, 'Cancel this call'), 'terminal calls cannot cancel');
}
}
});
Deno.test('caller can mark an underfilled call running when the game is unavailable locally', () => {
assertStartAvailable(renderCard(call(Date.now(), 6), 'Alice', null), 'Mark as running');
});
type ClickHandler = () => void | Promise<void>;
const findClickHandler = (node: ReactNode, label: string): ClickHandler | undefined => {
if (!isValidElement<{ children?: ReactNode; onClick?: ClickHandler }>(node)) return;
if (node.type === 'button' && buttonNamed(renderToStaticMarkup(node), label)) {
return node.props.onClick;
}
for (const child of Children.toArray(node.props.children)) {
const handler = findClickHandler(child, label);
if (handler) return handler;
}
};
// CardActions is controlled by NominationCard. Exercise its real button
// callbacks and rerender with the resulting confirmation state.
const startHarness = (
nomination: Nomination,
publication: Promise<boolean> = Promise.resolve(true),
localGame: Game | null = game,
) => {
let confirming = false;
const published: string[] = [];
const launched: Game[] = [];
const view = () => CardActions({
nomination,
game: localGame,
actorId: 'Alice',
actions: {
...actions,
startNow: callId => {
published.push(callId);
return publication;
},
},
now: Date.now(),
onLaunch: launchedGame => launched.push(launchedGame),
confirmStart: confirming,
onStartConfirmationChange: value => { confirming = value; },
});
return {
published,
launched,
markup: () => renderToStaticMarkup(view()),
click: (label: string) => {
const handler = findClickHandler(view(), label);
assert(handler, `${label} must have a click handler`);
return handler();
},
};
};
Deno.test('early start asks for confirmation without publishing or launching', async () => {
const now = Date.now();
for (const nomination of [
call(now, 6),
call(now),
call(now, 1, now + 60 * 60_000),
call(now, 1, now + 10 * 60_000),
]) {
const harness = startHarness(nomination);
await harness.click('Start now');
assert(harness.markup().includes('Start this call early?'), 'early-start question appears');
assert(buttonNamed(harness.markup(), 'Keep waiting'), 'waiting remains an explicit choice');
assert(harness.published.length === 0, 'first click must not publish Start');
assert(harness.launched.length === 0, 'first click must not launch');
}
});
Deno.test('keeping a call waiting dismisses confirmation and requires it again next time', async () => {
const harness = startHarness(call(Date.now(), 6));
await harness.click('Start now');
await harness.click('Keep waiting');
assert(!harness.markup().includes('Start this call early?'), 'confirmation closes');
assertStartAvailable(harness.markup());
await harness.click('Start now');
assert(harness.markup().includes('Start this call early?'), 'a later early start asks again');
assert(harness.published.length === 0, 'keeping the call waiting publishes nothing');
assert(harness.launched.length === 0, 'keeping the call waiting launches nothing');
});
Deno.test('confirming an early start launches only after core acceptance', async () => {
let accept!: (accepted: boolean) => void;
const publication = new Promise<boolean>(resolve => { accept = resolve; });
const nomination = call(Date.now(), 6);
const harness = startHarness(nomination, publication);
await harness.click('Start now');
const confirmation = harness.click('Start now');
assert(harness.published.length === 1, 'confirmation publishes exactly one Start');
assert(harness.published[0] === nomination.id, 'Start targets this call');
assertEquals(harness.launched.length, 0, 'launch waits for core acceptance');
accept(true);
await confirmation;
assert(harness.launched.at(0) === game, 'accepted Start launches the local game');
assert(harness.launched.length === 1, 'the game launches exactly once');
});
Deno.test('rejected early starts never launch and require confirmation on retry', async () => {
const harness = startHarness(call(Date.now(), 6), Promise.resolve(false));
await harness.click('Start now');
await harness.click('Start now');
assert(harness.published.length === 1, 'confirmation reaches the peer');
assert(harness.launched.length === 0, 'rejection cannot launch the game');
await harness.click('Start now');
assert(harness.markup().includes('Start this call early?'), 'retry requires confirmation');
assert(harness.published.length === 1, 'retry first click does not publish');
});
Deno.test('ready and elapsed calls still start with one click', async () => {
const now = Date.now();
for (const nomination of [call(now, 8), call(now, 1, null, now - 1_000)]) {
const harness = startHarness(nomination);
await harness.click('Start now');
assert(harness.published.length === 1, 'normal start publishes on the first click');
assert(harness.launched.length === 1, 'accepted normal start launches');
assert(!harness.markup().includes('Start this call early?'), 'normal start needs no confirmation');
}
});
Deno.test('confirmed early start without a local game only marks the call running', async () => {
const harness = startHarness(call(Date.now(), 6), Promise.resolve(true), null);
await harness.click('Mark as running');
assertEquals(harness.published.length, 0, 'marking early also requires confirmation');
await harness.click('Start now');
assert(harness.published.length === 1, 'confirmation publishes Start');
assert(harness.launched.length === 0, 'an unavailable game cannot launch locally');
});
+15 -3
View File
@@ -818,7 +818,7 @@ Source: `calltoplay.jsx` (the feature) + `ctp-chat.jsx` (per-call chat + shared
| Set up with | game + max players + **duration** (5/10/15/30/60 min) | game + max players + **clock time** (24h, + which day) | | Set up with | game + max players + **duration** (5/10/15/30/60 min) | game + max players + **clock time** (24h, + which day) |
| Others respond | `Ready now` or `+N minutes` | `I'm in` (RSVP), then check in later | | Others respond | `Ready now` or `+N minutes` | `I'm in` (RSVP), then check in later |
| Resolves | when the roster fills **or** the timer runs out | at the scheduled time, after a check-in window | | Resolves | when the roster fills **or** the timer runs out | at the scheduled time, after a check-in window |
| Who starts it | the **caller** decides the actual launch | the **caller**, once people have checked in | | Who starts it | the **caller** decides the actual launch | the **caller** decides, including before check-in |
**Check-in window.** `CHECKIN_LEAD_MS = 15 min`. A scheduled call sits in the **Check-in window.** `CHECKIN_LEAD_MS = 15 min`. A scheduled call sits in the
`scheduled` phase collecting RSVPs until 15 minutes before its start time, then `scheduled` phase collecting RSVPs until 15 minutes before its start time, then
@@ -830,6 +830,17 @@ When the deadline passes, the call is labeled **Time's up** rather than Ready.
It remains visible for five minutes so the caller can start or extend it, then It remains visible for five minutes so the caller can start or extend it, then
the call and its history expire as a unit. the call and its history expire as a unit.
The caller can use **Start now** at any time while the call is visible and has
not been started or cancelled, including before scheduled check-in. The player
count and deadline guide coordination; they do not gate starting. An
eight-player call can start with six ready players, or even the caller alone.
Before the roster is ready or the deadline has elapsed, clicking **Start now**
opens a small inline confirmation: **Start this call early?**, with **Start
now** and **Keep waiting** buttons. Keeping the call waiting publishes no
action. Ready and Time's-up calls start with one click. Starting marks the call
Running and then invokes the caller's local game action only after the peer
accepts it.
**Every call carries a small group chat** (see "Per-call chat" below). **Every call carries a small group chat** (see "Per-call chat" below).
### Three surfaces ### Three surfaces
@@ -892,8 +903,9 @@ Top to bottom:
avatar chips for each participant plus empty slots up to `maxPlayers`. avatar chips for each participant plus empty slots up to `maxPlayers`.
5. **Actions** — context-dependent on your role (**creator** / **participant** / 5. **Actions** — context-dependent on your role (**creator** / **participant** /
**outsider**) and phase: `Ready now` + `+5/10/15/30m` buffer buttons, **outsider**) and phase: `Ready now` + `+5/10/15/30m` buffer buttons,
`I'm in` (RSVP), `Start now` / `Add 5 more minutes` (creator once resolved), `I'm in` (RSVP), `Start now` (creator at any time before Start/Cancel),
`Leave` / `Can't make it`, or a status note. `Add 5 more minutes` (creator once resolved), `Leave` / `Can't make it`, or a
status note.
6. **Chat** — the collapsible per-call chat panel. 6. **Chat** — the collapsible per-call chat panel.
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm. 7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
+1 -1
View File
@@ -232,7 +232,7 @@ catalog-generate-production-game PACKAGES_DIR GAME_ID:
--game-id "$GAME_ID" --game-id "$GAME_ID"
frontend-test: frontend-test:
cd crates/lanspread-tauri-deno-ts && deno test --unstable-sloppy-imports tests cd crates/lanspread-tauri-deno-ts && deno test --config deno.test.json --allow-env=NODE_ENV --unstable-sloppy-imports tests
clean: clean:
cargo clean cargo clean