Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,26 @@ import { DeviceLicenseStatus } from '../components/device-license-status'
const DEVICE_ID = '659a3520540f803625ddc34081e893d3'
const BUY_URL = `https://edge.example.com/buy?vppId=com.openplc.espressif-licensed&deviceId=${DEVICE_ID}`

function setup(report: DeviceLicenseReport | null, overrides: { isChecking?: boolean; buyUrl?: string | null } = {}) {
function setup(
report: DeviceLicenseReport | null,
overrides: { isChecking?: boolean; buyUrl?: string | null; awaitingPurchase?: boolean } = {},
) {
const onBuy = jest.fn()
const onRecheck = jest.fn()
const onCancelPurchaseWatch = jest.fn()
render(
<DeviceLicenseStatus
report={report}
isChecking={overrides.isChecking ?? false}
// `??` would be wrong here: an explicit `buyUrl: null` is a case under test.
buyUrl={'buyUrl' in overrides ? (overrides.buyUrl ?? null) : BUY_URL}
awaitingPurchase={overrides.awaitingPurchase ?? false}
onBuy={onBuy}
onRecheck={onRecheck}
onCancelPurchaseWatch={onCancelPurchaseWatch}
/>,
)
return { onBuy, onRecheck }
return { onBuy, onRecheck, onCancelPurchaseWatch }
}

function expand() {
Expand All @@ -32,7 +38,15 @@ describe('DeviceLicenseStatus', () => {
// Connect. A placeholder badge would invite the user to read meaning into a
// check that never happened.
const { container } = render(
<DeviceLicenseStatus report={null} isChecking={false} buyUrl={null} onBuy={jest.fn()} onRecheck={jest.fn()} />,
<DeviceLicenseStatus
report={null}
isChecking={false}
buyUrl={null}
awaitingPurchase={false}
onBuy={jest.fn()}
onRecheck={jest.fn()}
onCancelPurchaseWatch={jest.fn()}
/>,
)
expect(container.firstChild).toBeNull()
})
Expand Down Expand Up @@ -85,8 +99,10 @@ describe('DeviceLicenseStatus', () => {
report={{ deviceId: DEVICE_ID, outcome }}
isChecking={false}
buyUrl={BUY_URL}
awaitingPurchase={false}
onBuy={jest.fn()}
onRecheck={jest.fn()}
onCancelPurchaseWatch={jest.fn()}
/>,
)
expect(container.textContent ?? '').not.toMatch(/full mode|unlocked|demo mode/i)
Expand All @@ -104,8 +120,10 @@ describe('DeviceLicenseStatus', () => {
report={{ deviceId: DEVICE_ID, outcome: { state: 'licensed', how: 'already-stored' } }}
isChecking={false}
buyUrl={BUY_URL}
awaitingPurchase={false}
onBuy={jest.fn()}
onRecheck={jest.fn()}
onCancelPurchaseWatch={jest.fn()}
/>,
)

Expand Down Expand Up @@ -211,4 +229,74 @@ describe('DeviceLicenseStatus', () => {
expect(screen.getByText(/not the same as having no licence/)).toBeTruthy()
})
})

describe('purchase watch', () => {
const UNLICENSED: DeviceLicenseReport = {
deviceId: DEVICE_ID,
outcome: { state: 'unlicensed', entitlementChecked: true },
}

it('reads "Waiting for purchase…" while the watch runs', () => {
setup(UNLICENSED, { awaitingPurchase: true })
expect(screen.getByText('Waiting for purchase…')).toBeTruthy()
})

it('outranks the periodic check tick — the badge must not flap between two labels', () => {
// Every poll tick flips isChecking on and off; alternating
// "Waiting…"/"Checking…" reads as flapping when it is one continuous wait.
setup(UNLICENSED, { awaitingPurchase: true, isChecking: true })
expect(screen.getByText('Waiting for purchase…')).toBeTruthy()
expect(screen.queryByText('Checking licence…')).toBeNull()
})

it('does NOT outrank a failed check — a dead link must not hide behind a calm wait', () => {
// If the link goes bad mid-wait, every tick lands a check-failed report;
// reading "Waiting for purchase…" over that would mask a real failure for
// the remaining minutes of the window. The failure label wins, and the
// watch keeps running underneath.
setup({ outcome: { state: 'check-failed', error: 'Request timeout' } }, { awaitingPurchase: true })
expect(screen.getByText('Licence check failed')).toBeTruthy()
expect(screen.queryByText('Waiting for purchase…')).toBeNull()
})

it('holds the failure label steady across poll ticks while waiting', () => {
// Mid-wait ticks still flip isChecking; with a check-failed report on
// record the badge must not flap to "Checking…" nor back to "Waiting…".
setup(
{ outcome: { state: 'check-failed', error: 'Request timeout' } },
{ awaitingPurchase: true, isChecking: true },
)
expect(screen.getByText('Licence check failed')).toBeTruthy()
expect(screen.queryByText('Checking licence…')).toBeNull()
expect(screen.queryByText('Waiting for purchase…')).toBeNull()
})

it('withdraws the purchase button while waiting — offering it again invites a double buy', () => {
setup(UNLICENSED, { awaitingPurchase: true })
expand()
expect(screen.queryByRole('button', { name: 'Buy licence' })).toBeNull()
})

it('offers to stop waiting, and only then', () => {
const { onCancelPurchaseWatch } = setup(UNLICENSED, { awaitingPurchase: true })
expand()
fireEvent.click(screen.getByRole('button', { name: 'Stop waiting' }))
expect(onCancelPurchaseWatch).toHaveBeenCalledTimes(1)
})

it('shows no stop button when no watch is running', () => {
setup(UNLICENSED)
expand()
expect(screen.queryByRole('button', { name: 'Stop waiting' })).toBeNull()
})

it('explains that OpenPLC will write the licence by itself', () => {
setup(UNLICENSED, { awaitingPurchase: true })
expand()
// The subject is deliberately product-neutral ("OpenPLC", not "the
// editor"): the same bytes render in the desktop editor and the web IDE.
expect(screen.getByText(/OpenPLC checks periodically/)).toBeTruthy()
expect(screen.getByText(/write the licence to this device by itself/)).toBeTruthy()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ const Board = memo(function () {
} = useDeviceConnect(currentBoardInfo)

// VPP licensing. Inert for every board whose VPP is not sold licensed, which is
// every built-in board — `isLicensable` gates the whole affordance.
const licensing = useDeviceLicense(currentBoardInfo)
// every built-in board — `isLicensable` gates the whole affordance. This mount
// OWNS the purchase watch: `useDeviceConnect` above holds a second instance of
// the hook, and without a single owner both would poll on every watch tick.
const licensing = useDeviceLicense(currentBoardInfo, { ownsWatch: true })

// Whether this target exposes the GPIO pin-mapping table. Arduino boards
// enable it via their preset; runtime-v4 GPIO boards (e.g. the Raspberry
Expand Down Expand Up @@ -720,8 +722,10 @@ const Board = memo(function () {
report={licensing.report}
isChecking={licensing.isChecking}
buyUrl={licensing.buyUrl}
awaitingPurchase={licensing.awaitingPurchase}
onBuy={() => void licensing.buy()}
onRecheck={() => void licensing.refresh()}
onCancelPurchaseWatch={licensing.cancelPurchaseWatch}
/>
) : null}
</DeviceConnectButton>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,16 @@ export interface DeviceLicenseStatusProps {
isChecking: boolean
/** Null when no valid purchase link can be built; the button is then hidden. */
buyUrl: string | null
/**
* True while the purchase watch runs — `buy` opened the external purchase
* page and OpenPLC is polling so it can write the licence on its own. The
* badge then reads "Waiting for purchase…" (unless the last report is a
* check-failed, which outranks the wait) and the panel can stop it.
*/
awaitingPurchase: boolean
onBuy: () => void
onRecheck: () => void
onCancelPurchaseWatch: () => void
}

/** The label + icon for an outcome. One place, so no branch can drift. */
Expand Down Expand Up @@ -131,7 +139,15 @@ function describeOutcome(report: DeviceLicenseReport): {
}
}

export function DeviceLicenseStatus({ report, isChecking, buyUrl, onBuy, onRecheck }: DeviceLicenseStatusProps) {
export function DeviceLicenseStatus({
report,
isChecking,
buyUrl,
awaitingPurchase,
onBuy,
onRecheck,
onCancelPurchaseWatch,
}: DeviceLicenseStatusProps) {
const [copied, setCopied] = useState(false)

// Nothing has run: every non-licensable board stays here, and so does a
Expand All @@ -148,10 +164,29 @@ export function DeviceLicenseStatus({ report, isChecking, buyUrl, onBuy, onReche
const { label, Icon, negative, detail } = describeOutcome(report)
const deviceId = report.deviceId

// The watch outranks the tick, but never a FAILURE. While the watch runs,
// every periodic refresh flips `isChecking` on and off, and a badge
// alternating "Waiting…"/"Checking…" reads as flapping when it is one
// continuous wait — so the waiting label absorbs the ticks. A check-failed
// report is different: it means the ticks currently cannot see the device,
// and a calm "Waiting for purchase…" over that would hide a dead link for
// up to ten minutes. The failure label (and its normal styling) wins, held
// steady across ticks; the watch keeps running underneath.
const checkFailed = report.outcome.state === 'check-failed'
const showWaiting = awaitingPurchase && !checkFailed
const badgeLabel = showWaiting
? 'Waiting for purchase…'
: isChecking && !awaitingPurchase
? 'Checking licence…'
: label

// The purchase button appears ONLY where buying is the honest next step: the
// backend was asked and reported no entitlement. On `check-failed` or an
// unchecked `unlicensed` it would be a guess, and a costly one.
const offerPurchase = !!buyUrl && report.outcome.state === 'unlicensed' && report.outcome.entitlementChecked === true
// unchecked `unlicensed` it would be a guess, and a costly one. While the
// watch runs the step was already taken — offering it again mid-wait invites
// a double purchase.
const offerPurchase =
!!buyUrl && !awaitingPurchase && report.outcome.state === 'unlicensed' && report.outcome.entitlementChecked === true

return (
// Radix Popover, PORTALLED. The details used to be a conditional <div> in the
Expand All @@ -171,9 +206,13 @@ export function DeviceLicenseStatus({ report, isChecking, buyUrl, onBuy, onReche
: 'text-neutral-600 hover:text-neutral-950 dark:text-neutral-400 dark:hover:text-white',
)}
>
{isChecking ? <ShieldUnknownIcon size={10} /> : <Icon size={10} />}
<span className={cn(negative && 'border-b border-dashed border-neutral-400 dark:border-neutral-700')}>
{isChecking ? 'Checking licence…' : label}
{showWaiting || isChecking ? <ShieldUnknownIcon size={10} /> : <Icon size={10} />}
<span
className={cn(
negative && !showWaiting && 'border-b border-dashed border-neutral-400 dark:border-neutral-700',
)}
>
{badgeLabel}
</span>
</button>
</Popover.Trigger>
Expand Down Expand Up @@ -226,6 +265,13 @@ export function DeviceLicenseStatus({ report, isChecking, buyUrl, onBuy, onReche
</div>
) : null}

{awaitingPurchase ? (
<p className='font-caption text-cp-sm text-neutral-600 dark:text-neutral-400'>
Waiting for the purchase to complete. OpenPLC checks periodically and will write the licence to this
device by itself — you can keep working meanwhile.
</p>
) : null}

<div className='flex items-center gap-3'>
<button
type='button'
Expand All @@ -240,6 +286,15 @@ export function DeviceLicenseStatus({ report, isChecking, buyUrl, onBuy, onReche
Buy licence
</button>
) : null}
{awaitingPurchase ? (
<button
type='button'
onClick={onCancelPurchaseWatch}
className='font-caption text-cp-xs text-neutral-600 hover:underline dark:text-neutral-400'
>
Stop waiting
</button>
) : null}
</div>
</Popover.Content>
</Popover.Portal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,42 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa
const handleSimulatorControl = useCallback(async (): Promise<void> => {
try {
if (simulatorRunning) {
await debugSession.stopSession()
// Two things end here, in this order, and the emulator's end is not
// conditional on the debug session's.
//
// The debug session goes first: it is a CONSUMER of the emulator, so it
// has to let go of the transport before the thing on the other end
// disappears.
//
// The emulator goes second, from a `finally`, because stopping it is
// this button's job and nothing else's. `stopSession()` deliberately
// does not do it (see its docstring: a debug session is not the owner of
// the thing it talks to), so with this call missing "Stop" ended the
// debug session, logged "Simulator stopped." and left the avr8js loop
// running — re-scheduling itself and burning a core for the rest of the
// session, unreachable because the button had flipped back to "Start".
//
// Sequencing it after a plain `await` reintroduced the same leak on the
// error path: anything that rejects inside the teardown (today only a
// throwing `onDisconnected` subscriber, which is why nothing hits it
// yet) skipped straight to the catch below, which only logs. The
// emulator kept running, `simulatorRunning` stayed true, and every
// retry failed identically — worse than the original bug, because
// nothing settled at all. `finally` keeps the order and drops the
// condition.
try {
await debugSession.stopSession()
} finally {
await simulator.stop()
}

// The session this debug session was riding is gone, so the claim that it
// rides one goes with it. The drop handler cannot clear it on this path:
// `stopSession()` has already hidden the debugger, so the handler's
// `isDebuggerVisible` gate returns before it reaches the reset — leaving
// the ref stale-`true` for a session that no longer exists.
debugSessionRidesDeviceRef.current = false

setSimulatorRunning(false)
addLog({ id: crypto.randomUUID(), level: 'info', message: 'Simulator stopped.' })
} else {
Expand All @@ -649,7 +684,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa
pendingSimulatorDebugRef.current = false
addLog({ id: crypto.randomUUID(), level: 'error', message: `Simulator control error: ${getErrorMessage(error)}` })
}
}, [debugSession, simulatorRunning, addLog])
}, [debugSession, simulator, simulatorRunning, addLog])

// ---------------------------------------------------------------------------
// MD5 verification — runs after debug compilation for non-simulator
Expand Down
Loading
Loading