Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
@@ -1,3 +1,3 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
import type { TimingStats } from '@root/middleware/shared/ports/types'
import { useCapabilities, useDevice, useRuntime } from '@root/middleware/shared/providers/platform-context'
Expand Down Expand Up @@ -65,8 +65,10 @@
} = 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 @@
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
@@ -1,3 +1,3 @@
/**
* Licence status for the device screen: a quiet, monochrome line next to
* "Connected", plus a details panel with the device id and the actions the
Expand Down Expand Up @@ -75,8 +75,16 @@
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 @@
}
}

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 @@
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 @@
: '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 @@
</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 @@
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
Loading
Loading