diff --git a/README.md b/README.md index b4d38c0..b0550ba 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ writing any glue code. - **Mock imports interactively.** Any import that isn't WASI and that you haven't implemented asks for its return value in the console when the guest calls it, and the guest waits for your answer. This includes methods of imported/host resources, which can also be passed to exports as mock arguments. -- **Implement imports in JavaScript.** Expand the **Imports** card to write an ES module for - an imported interface, then **Apply and restart**. Functions you leave out still get mocked interactively. +- **Implement imports in JavaScript.** Click **Implement in JavaScript** on the **Imports** bar to write an ES + module for an imported interface, then **Apply and restart**. Functions you leave out still get mocked interactively. - **Resource table.** Tracks each resource handle: who created it, whether it is live, moved or dropped, and which handle index it reuses. Live instances can be picked as arguments, and dropped from the table. diff --git a/src/app.ts b/src/app.ts index 9ce7b46..4dc446e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -73,7 +73,8 @@ export class ComponentApp extends LitElement { @query('console-panel') accessor consolePanel!: ConsolePanel | null; @query('#wasmFileInput') accessor fileInput!: HTMLInputElement; @query('.component-pane') accessor componentPane!: HTMLElement | null; - @query('#importsCard') accessor importsCard!: (HTMLElement & { open: boolean }) | null; + // Whether the dialog implementing imports in JavaScript is open. + @state() accessor editingImports = false; // Guards against a slow load finishing after a newer one has started. private loadCounter = 0; @@ -211,23 +212,47 @@ export class ComponentApp extends LitElement { `; } - // Collapsed, since calls to imports ask in the console by default. Expand it to implement - // imports in JavaScript instead, and restart the component with them. It opens if that fails. + // Calls to imports ask in the console by default. The dialog implements them in JavaScript + // instead, and restarts the component with them; it stays open if that fails. private renderImports(s: Session) { const count = s.component.customImports.length; const implemented = Object.entries(s.appliedCode).filter(([pkg, code]) => code.trim() !== '' && code !== s.templates[pkg]).length; - return html` -
+ const pending = s.component.customImports.some((pkg) => (s.importCode[pkg] ?? '') !== (s.appliedCode[pkg] ?? '')); + return html`
+
Imports ${count} interface${count === 1 ? '' : 's'} ${implemented > 0 ? html`${implemented} in JavaScript` : nothing} - Calls to imports ask for their results in the console. Expand to implement imports in - JavaScript instead; the functions you leave out still ask. + Calls to imports ask for their results in the console. Implement them in JavaScript + instead; the functions you leave out still ask.
+ (this.editingImports = true)}> + + Implement in JavaScript + +
+ { + if (e.target === e.currentTarget) { + this.editingImports = false; + } + }} + > +

+ Each interface is an ES module exporting its functions and resource classes. The functions + you leave out ask for their results in the console. +

+ ${s.status === 'failed' + ? html`Instantiation failed
${errorMessage(s.error)}
` + : nothing}
${s.component.customImports.map((pkg) => html``)}
-
+
+ ${pending ? html`Not applied yet` : nothing} Apply and restart - ${s.component.customImports.some((pkg) => (s.importCode[pkg] ?? '') !== (s.appliedCode[pkg] ?? '')) - ? html`Not applied yet` - : nothing}
- `; + `; } private renderExportsArea(s: Session) { @@ -291,6 +314,7 @@ export class ComponentApp extends LitElement { status: 'instantiating', generation: 0, }; this.session = session; + this.editingImports = false; this.instantiate(session, templates); } catch (err) { if (id !== this.loadCounter) { @@ -340,11 +364,9 @@ export class ComponentApp extends LitElement { } this.requestUpdate(); if (s.status === 'ready') { - // Applied imports collapse; show the beginning of the exports. + // Applied imports close the dialog; show the beginning of the exports. + this.editingImports = false; await this.updateComplete; - if (this.importsCard) { - this.importsCard.open = false; - } this.componentPane?.scrollTo({ top: 0 }); } } diff --git a/src/components.ts b/src/components.ts index 3bee1b0..2d65822 100644 --- a/src/components.ts +++ b/src/components.ts @@ -4,12 +4,23 @@ import '@awesome.me/webawesome/dist/components/badge/badge.js'; import '@awesome.me/webawesome/dist/components/button/button.js'; import '@awesome.me/webawesome/dist/components/callout/callout.js'; import '@awesome.me/webawesome/dist/components/card/card.js'; +import '@awesome.me/webawesome/dist/components/checkbox-group/checkbox-group.js'; +import '@awesome.me/webawesome/dist/components/checkbox/checkbox.js'; import '@awesome.me/webawesome/dist/components/copy-button/copy-button.js'; import '@awesome.me/webawesome/dist/components/details/details.js'; -import '@awesome.me/webawesome/dist/components/dropdown/dropdown.js'; +import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; +import '@awesome.me/webawesome/dist/components/dropdown/dropdown.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; +import '@awesome.me/webawesome/dist/components/input/input.js'; +import '@awesome.me/webawesome/dist/components/number-input/number-input.js'; +import '@awesome.me/webawesome/dist/components/option/option.js'; +import '@awesome.me/webawesome/dist/components/radio-group/radio-group.js'; +import '@awesome.me/webawesome/dist/components/radio/radio.js'; +import '@awesome.me/webawesome/dist/components/select/select.js'; import '@awesome.me/webawesome/dist/components/spinner/spinner.js'; import '@awesome.me/webawesome/dist/components/split-panel/split-panel.js'; +import '@awesome.me/webawesome/dist/components/switch/switch.js'; +import '@awesome.me/webawesome/dist/components/tag-input/tag-input.js'; import '@awesome.me/webawesome/dist/components/tag/tag.js'; import '@awesome.me/webawesome/dist/components/textarea/textarea.js'; diff --git a/src/draft.ts b/src/draft.ts index d7171cc..2c1a979 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -17,7 +17,10 @@ export interface ListDraft extends DraftBase { kind: 'list'; length: string; ite export interface FieldsDraft extends DraftBase { kind: 'fields'; fields: Draft[] } // Variants and enums; `index` is -1 until a case is selected. export interface CaseDraft extends DraftBase { kind: 'case'; index: number; payload?: Draft } -export type Draft = NullDraft | TextDraft | BoolDraft | OptDraft | ListDraft | FieldsDraft | CaseDraft; +// A `list`, edited as text encoded as UTF-8, or as hex. +export type ByteEncoding = 'text' | 'hex'; +export interface BytesDraft extends DraftBase { kind: 'bytes'; text: string; encoding: ByteEncoding } +export type Draft = NullDraft | TextDraft | BoolDraft | OptDraft | ListDraft | FieldsDraft | CaseDraft | BytesDraft; export const INVALID = Symbol('invalid'); @@ -47,7 +50,44 @@ export function selectedInstance( return d.text === '' && instanceChoices(t, registry, giver).length === 0 && mock?.canMock(t) ? NEW_MOCK : d.text; } -const MAX_LIST_LENGTH = 100; +export const MAX_LIST_LENGTH = 100; + +export function isBytes(ty: IDL.Type): boolean { + return ty instanceof IDL.FixedNatClass && ty._bits === 8; +} + +export function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(' '); +} + +// Hex bytes, optionally separated by spaces or commas and prefixed with `0x`, e.g. `48 69`. +function parseHex(text: string): Uint8Array { + const digits = text.replace(/0x/gi, '').replace(/[\s,]/g, ''); + if (!/^[0-9a-f]*$/i.test(digits) || digits.length % 2 !== 0) { + throw new Error('Expected hex bytes, e.g. "48 69"'); + } + return Uint8Array.from(digits.match(/../g) ?? [], (b) => parseInt(b, 16)); +} + +// The bytes of a draft; throws if they are invalid hex. +export function bytesOf(d: BytesDraft): Uint8Array { + return d.encoding === 'text' ? new TextEncoder().encode(d.text) : parseHex(d.text); +} + +// Converts the text to the other encoding, keeping the bytes. Bytes that aren't UTF-8 stay hex. +export function setEncoding(d: BytesDraft, encoding: ByteEncoding) { + if (encoding === d.encoding) { + return; + } + try { + const bytes = bytesOf(d); + d.text = encoding === 'hex' ? toHex(bytes) : new TextDecoder('utf-8', { fatal: true }).decode(bytes); + d.encoding = encoding; + d.error = undefined; + } catch (err) { + d.error = err instanceof TypeError ? 'These bytes aren\'t valid UTF-8 text' : (err as Error).message; + } +} class Init extends IDL.Visitor { public visitType(_t: IDL.Type, _d: null): Draft { @@ -63,8 +103,8 @@ class Init extends IDL.Visitor { public visitOpt(_t: IDL.OptClass, _ty: IDL.Type, _d: null): Draft { return { kind: 'opt', some: false }; } - public visitVec(_t: IDL.VecClass, _ty: IDL.Type, _d: null): Draft { - return { kind: 'list', length: '', items: [] }; + public visitVec(_t: IDL.VecClass, ty: IDL.Type, _d: null): Draft { + return isBytes(ty) ? { kind: 'bytes', text: '', encoding: 'text' } : { kind: 'list', length: '', items: [] }; } public visitRecord(_t: IDL.RecordClass, fields: Record, _d: null): Draft { return { kind: 'fields', fields: Object.values(fields).map(initDraft) }; @@ -101,6 +141,17 @@ export function resizeList(d: ListDraft, ty: IDL.Type, length: string) { d.items = Array.from({ length: n }, (_, i) => d.items[i] ?? initDraft(ty)); } +export function removeItem(d: ListDraft, index: number) { + d.items.splice(index, 1); + d.length = `${d.items.length}`; +} + +// Replaces the items of a list edited as tags, e.g. a list of numbers. +export function setTextItems(d: ListDraft, texts: string[]) { + d.items = texts.slice(0, MAX_LIST_LENGTH).map((text) => ({ kind: 'text', text })); + d.length = `${d.items.length}`; +} + // `ty` is the payload type of the selected case; enums have none. export function selectCase(d: CaseDraft, index: number, ty?: IDL.Type) { d.index = index; @@ -112,6 +163,10 @@ function randomInt(max: number): number { return Math.floor(Math.random() * max); } +function randomString(): string { + return Math.random().toString(36).substring(6); +} + function randomNumber(signed: boolean): number { const num = randomInt(100); return signed && Math.random() < 0.5 ? -num : num; @@ -124,7 +179,7 @@ class Randomize extends IDL.Visitor { } public visitType(_t: IDL.Type, _d: Draft): void {} public visitString(_t: IDL.StringClass, d: Draft): void { - this.fillText(d as TextDraft, () => Math.random().toString(36).substring(6)); + this.fillText(d as TextDraft, randomString); } public visitChar(_t: IDL.CharClass, d: Draft): void { this.fillText(d as TextDraft, () => String.fromCharCode(97 + randomInt(26))); @@ -163,6 +218,12 @@ class Randomize extends IDL.Visitor { } } public visitVec(_t: IDL.VecClass, ty: IDL.Type, d: Draft): void { + if (d.kind === 'bytes') { + this.fillText(d, () => d.encoding === 'text' + ? randomString() + : toHex(Uint8Array.from({ length: 1 + randomInt(8) }, () => randomInt(256)))); + return; + } const list = d as ListDraft; if (list.length === '' || list.auto) { resizeList(list, ty, `${randomInt(4)}`); @@ -196,7 +257,7 @@ class Randomize extends IDL.Visitor { public visitRec(_t: IDL.RecClass, ty: IDL.Type, d: Draft): void { ty.accept(this, d); } - private fillText(d: TextDraft, generate: () => string) { + private fillText(d: TextDraft | BytesDraft, generate: () => string) { if (d.text === '' || d.auto) { d.text = generate(); d.auto = true; @@ -303,6 +364,10 @@ class Parse extends IDL.Visitor { return tagged ? { tag: 'some', val } : val; } public visitVec(_t: IDL.VecClass, ty: IDL.Type, d: Draft): any { + if (d.kind === 'bytes') { + // As jco represents a `list`. + return this.leaf(d, () => bytesOf(d)); + } return this.all((d as ListDraft).items.map((item) => ty.accept(this, item))); } public visitRecord(_t: IDL.RecordClass, fields: Record, d: Draft): any { diff --git a/src/form.ts b/src/form.ts index 0669460..e266824 100644 --- a/src/form.ts +++ b/src/form.ts @@ -1,10 +1,12 @@ import { html, nothing, TemplateResult } from 'lit'; import { classMap } from 'lit/directives/class-map.js'; +import { guard } from 'lit/directives/guard.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import * as IDL from './wit'; import { - BoolDraft, CaseDraft, Draft, FieldsDraft, Giver, ListDraft, MockFactory, NEW_MOCK, OptDraft, TextDraft, - instanceChoices, parseDraft, resizeList, selectCase, selectedInstance, setSome, + BoolDraft, ByteEncoding, BytesDraft, CaseDraft, Draft, FieldsDraft, Giver, ListDraft, MAX_LIST_LENGTH, MockFactory, NEW_MOCK, OptDraft, TextDraft, + bytesOf, instanceChoices, parseDraft, removeItem, resizeList, selectCase, selectedInstance, setEncoding, setSome, setTextItems, } from './draft'; import { ResourceRegistry } from './resources'; @@ -27,53 +29,135 @@ interface RenderData { const inputValue = (e: Event) => (e.target as HTMLInputElement).value; -function controlClass(d: Draft) { - return classMap({ reject: !!d.error, auto: !!d.auto }); +function controlClass(d: Draft, extra: Record = {}) { + return classMap({ reject: !!d.error, auto: !!d.auto, ...extra }); } -function subform(content: Rendered[], nested = true): Rendered { +// With `flow`, short fields share a row, e.g. the fields of a record. +function subform(content: Rendered[], { nested = true, flow = false } = {}): Rendered { const visible = content.filter((c) => c !== nothing); if (visible.length === 0) { return nothing; } - return html`
${visible}
`; + return html`
${visible}
`; } +// Types with a short control, which can share a row with others. Variants with a payload show it +// below their cases, so they take a row of their own. +function isShort(t: IDL.Type): boolean { + return isNumber(t) || t instanceof IDL.BoolClass || t instanceof IDL.CharClass || t instanceof IDL.EnumClass + || t instanceof IDL.ResourceClass || t instanceof IDL.OwnedClass || t instanceof IDL.BorrowClass + || (t instanceof IDL.VariantClass && Object.values(t._fields).every((ty) => ty instanceof IDL.NullClass)); +} + +// Cases fit in a row of buttons up to these limits; more get a dropdown. +const SEGMENTED_MAX_CASES = 4; +const SEGMENTED_MAX_CHARS = 24; + +// Picks one of `cases`; `index` is -1 while none is selected. +function renderCases(d: Draft, cases: string[], index: number, onSelect: (index: number) => void, result = false): Rendered { + // `live` so programmatic changes, e.g. by Random, apply after user interaction. + const value = live(index >= 0 ? cases[index] : ''); + const select = (e: Event) => { + d.auto = false; + onSelect(cases.indexOf(inputValue(e))); + }; + if (cases.length <= SEGMENTED_MAX_CASES && cases.join('').length <= SEGMENTED_MAX_CHARS) { + return html` + ${cases.map((tag) => html`${tag}`)} + `; + } + const placeholder = 'Select a case…'; + return html` + ${cases.map((tag) => html`${tag}`)} + `; +} + +// A dropdown is as wide as its longest label, rather than the field. +function selectWidth(labels: string[]): string { + return `--chars: ${Math.max(...labels.map((label) => label.length))}`; +} + +// jco lowers `result` as a variant with these cases. +function isResult(fields: Record): boolean { + const tags = Object.keys(fields); + return tags.length === 2 && tags[0] === 'ok' && tags[1] === 'err'; +} + +function isNumber(t: IDL.Type): boolean { + return t instanceof IDL.FixedNatClass || t instanceof IDL.FixedIntClass || t instanceof IDL.FixedFloatClass; +} + +interface NumberBounds { + min?: number; + max?: number; + float?: boolean; + // The width of the widest value, which sizes the input. + digits: number; +} + +// Bounds as numbers, which are exact up to 32 bits; 64-bit integers are range-checked when parsed instead. +function integerBounds(bits: number, signed: boolean): NumberBounds { + const largest = (1n << BigInt(signed ? bits - 1 : bits)) - 1n; + const digits = `${largest}`.length + (signed ? 1 : 0); + if (bits > 32) { + return signed ? { digits } : { min: 0, digits }; + } + return { min: signed ? -Number(largest) - 1 : 0, max: Number(largest), digits }; +} + +// Room for most floats as typed, e.g. `-1234.5678e-10`; longer ones scroll. +const FLOAT_DIGITS = 14; + class Render extends IDL.Visitor { - // Strings and numbers. + // Strings and chars. public visitType(t: IDL.Type, { d, ctx }: RenderData): Rendered { const text = d as TextDraft; - return html` { - text.text = inputValue(e); - text.auto = false; - text.error = undefined; - ctx.update(); - }} - @blur=${() => { - if (text.text !== '') { - parseDraft(t, d, ctx.registry, ctx.mock, ctx.giver); - ctx.update(); - } - }} - />`; + @input=${(e: Event) => editText(text, inputValue(e), ctx)} + @blur=${() => commitText(t, text, ctx)} + >`; + } + public visitFixedNat(t: IDL.FixedNatClass, data: RenderData): Rendered { + return renderNumber(t, data, integerBounds(t._bits, false)); + } + public visitFixedInt(t: IDL.FixedIntClass, data: RenderData): Rendered { + return renderNumber(t, data, integerBounds(t._bits, true)); + } + public visitFixedFloat(t: IDL.FixedFloatClass, data: RenderData): Rendered { + return renderNumber(t, data, { float: true, digits: FLOAT_DIGITS }); } public visitNull(_t: IDL.NullClass, _data: RenderData): Rendered { return nothing; } public visitBool(_t: IDL.BoolClass, { d, ctx }: RenderData): Rendered { const bool = d as BoolDraft; - return html` { bool.checked = (e.target as HTMLInputElement).checked; ctx.update(); }} - />`; + >${bool.checked ? 'true' : 'false'}`; } // Picks one of the instances of the resource the giver owns. public visitResource(t: IDL.ResourceClass, { d, ctx }: RenderData): Rendered { @@ -83,82 +167,79 @@ class Render extends IDL.Visitor { const mock = !!ctx.mock?.canMock(t); const id = selectedInstance(t, text, ctx.registry, ctx.mock, giver); const selected = instances.some((i) => i.id === id) || (mock && id === NEW_MOCK); - return html``; + ${mock ? html`${mockLabel}` : nothing} + ${instances.map((i, n) => html`${labels[n]}`)} + `; } + // An option is a variant of `none` and `some(T)`. public visitOpt(_t: IDL.OptClass, ty: IDL.Type, { d, ctx }: RenderData): Rendered { const opt = d as OptDraft; return html` - + ${renderCases(opt, ['none', 'some'], opt.some ? 1 : 0, (index) => { + setSome(opt, ty, index === 1); + ctx.update(); + })} ${opt.some ? subform([renderField(ty, opt.inner!, ctx)]) : nothing} `; } - public visitVec(_t: IDL.VecClass, ty: IDL.Type, { d, ctx }: RenderData): Rendered { - const list = d as ListDraft; - return html` - { - resizeList(list, ty, inputValue(e)); - list.auto = false; - ctx.update(); - }} - /> - ${subform(list.items.map((item, i) => renderField(ty, item, ctx, `[${i}]`)))} - `; + public visitVec(t: IDL.VecClass, ty: IDL.Type, data: RenderData): Rendered { + if (data.d.kind === 'bytes') { + return renderBytes(t, data); + } + return isNumber(ty) ? renderTags(ty, data) : renderItems(ty, data); } public visitRecord(_t: IDL.RecordClass, fields: Record, { d, ctx }: RenderData): Rendered { const entries = Object.entries(fields); const record = d as FieldsDraft; - return subform(entries.map(([key, ty], i) => renderField(ty, record.fields[i], ctx, key)), entries.length > 1); + return subform(entries.map(([key, ty], i) => renderField(ty, record.fields[i], ctx, key)), { nested: entries.length > 1, flow: true }); + } + public visitFlags(t: IDL.FlagsClass, _fields: Record, { d, ctx }: RenderData): Rendered { + const flags = (d as FieldsDraft).fields as BoolDraft[]; + return html` + ${t._flags.map((flag, i) => html` { + flags[i].checked = (e.target as HTMLInputElement).checked; + ctx.update(); + }} + >${flag}`)} + `; } public visitTuple(_t: IDL.TupleClass, components: IDL.Type[], { d, ctx }: RenderData): Rendered { const tuple = d as FieldsDraft; - return subform(components.map((ty, i) => renderField(ty, tuple.fields[i], ctx, `${i}`)), components.length > 1); + return subform(components.map((ty, i) => renderField(ty, tuple.fields[i], ctx, `${i}`)), { nested: components.length > 1, flow: true }); } public visitVariant(_t: IDL.VariantClass, fields: Record, { d, ctx }: RenderData): Rendered { const entries = Object.entries(fields); const variant = d as CaseDraft; return html` - ${renderSelect(variant, entries.map(([tag]) => tag), (index) => { + ${renderCases(variant, entries.map(([tag]) => tag), variant.index, (index) => { selectCase(variant, index, entries[index][1]); ctx.update(); - })} + }, isResult(fields))} ${variant.payload ? subform([renderField(entries[variant.index][1], variant.payload, ctx)]) : nothing} `; } public visitEnum(_t: IDL.EnumClass, tags: Array, { d, ctx }: RenderData): Rendered { const enumDraft = d as CaseDraft; - return renderSelect(enumDraft, tags, (index) => { + return renderCases(enumDraft, tags, enumDraft.index, (index) => { selectCase(enumDraft, index); ctx.update(); }); @@ -168,19 +249,152 @@ class Render extends IDL.Visitor { } } -function renderSelect(d: CaseDraft, cases: string[], onSelect: (index: number) => void): Rendered { - // `.selected` (not the attribute) so programmatic changes apply after user interaction. - return html``; + > + ${invalid >= 0 ? html`
[${invalid}]: ${list.items[invalid].error}
` : nothing}`; +} + +// Other lists are edited item by item. +function renderItems(ty: IDL.Type, { d, ctx }: RenderData): Rendered { + const list = d as ListDraft; + const edited = () => { + list.auto = false; + ctx.update(); + }; + return html` + ${subform(list.items.map((item, i) => renderField(ty, item, ctx, `[${i}]`, html` { + removeItem(list, i); + edited(); + }} + > + + `)))} + = MAX_LIST_LENGTH} + @click=${() => { + resizeList(list, ty, `${list.items.length + 1}`); + edited(); + }} + > + + Add item + + `; } const render = new Render(); @@ -194,19 +408,22 @@ export function typeName(type: IDL.Type) { return html`${short}`; } -// Renders a labeled input for a value of type `t`, backed by the draft `d`. -export function renderField(t: IDL.Type, d: Draft, ctx: FormContext, label?: string): Rendered { +// Renders a labeled input for a value of type `t`, backed by the draft `d`. `action`, e.g. a button +// removing a list item, goes at the end of the label. +export function renderField(t: IDL.Type, d: Draft, ctx: FormContext, label?: string, action?: TemplateResult): Rendered { const control = t.accept(render, { d, ctx }); if (!label && control === nothing && !d.error) { return nothing; } - return html`
- ${label - ? html`` - : nothing} + const heading = label + ? html`` + : nothing; + // Outside the label, which would otherwise pass its clicks on to the action. + return html`
+ ${action ? html`
${heading}${action}
` : heading} ${control} ${d.error ? html`
${d.error}
` : nothing}
`; diff --git a/src/func-card.ts b/src/func-card.ts index 745ea40..3006d91 100644 --- a/src/func-card.ts +++ b/src/func-card.ts @@ -78,7 +78,7 @@ export class FuncCard extends LitElement { ${renderSignature(this.name, this.func)} ${this.target.type === 'func' ? nothing : html`${this.target.type}`}
-
+
${args.length === 0 ? html`No arguments` : args.map(([arg, type], i) => renderField(type, this.drafts[i], form, arg))} diff --git a/src/style.css b/src/style.css index efebf8f..5e1c35d 100644 --- a/src/style.css +++ b/src/style.css @@ -149,22 +149,42 @@ pre { font-weight: var(--wa-font-weight-normal); } .imports-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--wa-space-m); flex-shrink: 0; + padding: var(--wa-space-m) var(--wa-space-l); background: var(--wa-color-surface-default); + border: var(--wa-border-width-s) solid var(--wa-color-surface-border); + border-radius: var(--wa-border-radius-l); } -.imports-card::part(content) { - display: flex; - flex-direction: column; - gap: var(--wa-space-l); -} -.imports-pending { - align-self: center; +.imports-card wa-button { + flex-shrink: 0; } .imports-title { display: flex; align-items: center; gap: var(--wa-space-s); } +/* It shows in the top layer; out of the flow, the pane doesn't make room for it. */ +.imports-dialog { + position: absolute; + --width: min(56rem, 90vw); +} +.imports-dialog::part(body) { + display: flex; + flex-direction: column; + gap: var(--wa-space-m); +} +.imports-help { + margin: 0; +} +.imports-dialog .func-actions { + align-items: center; + justify-content: flex-end; + gap: var(--wa-space-m); +} .imports { display: flex; flex-direction: column; @@ -296,22 +316,71 @@ func-card.highlight { overflow: hidden; text-overflow: ellipsis; } -.field input:not([type='checkbox']), -.field select { - --wa-form-control-height: 2.25rem; - font-size: var(--wa-font-size-s); +/* Controls that don't fill the width keep their natural size. */ +.field :is(wa-switch, wa-radio-group, wa-checkbox-group, .add-item) { + align-self: flex-start; } -.field input[type='checkbox'] { +.field wa-input.char { + width: 6rem; +} +/* Wide enough for the longest label, and the expand icon. */ +.field wa-select { align-self: flex-start; + width: calc(var(--chars) * 1ch + 3.5rem); + max-width: 100%; } -.field input.length { - width: 8rem; +/* Wide enough for the largest value of the type. */ +.field wa-number-input { + width: calc(var(--digits) * 1ch + 2rem); + max-width: 100%; } -.checkbox-label { - display: inline-flex; +/* Aligned like text inputs: the empty start and end slots pad it, but mustn't grow. */ +.field wa-number-input::part(input) { + padding-inline: 0; + text-align: start; +} +.field wa-number-input::part(start), +.field wa-number-input::part(end) { + flex: none; +} +.field wa-switch.bool { + font-family: var(--wa-font-family-code); + font-size: var(--wa-font-size-s); +} +.field wa-checkbox-group.flags { + --gap: var(--wa-space-xs) var(--wa-space-m); +} +.field wa-checkbox-group.flags::part(form-control-input) { + flex-wrap: wrap; +} +/* The selected case of a `result` takes the color of success or failure. */ +wa-radio-group.result wa-radio[value='ok']:state(checked) { + border-color: var(--wa-color-success-border-loud); + background-color: var(--wa-color-success-fill-quiet); +} +wa-radio-group.result wa-radio[value='err']:state(checked) { + border-color: var(--wa-color-danger-border-loud); + background-color: var(--wa-color-danger-fill-quiet); +} +.bytes-mode { + display: flex; + align-items: center; + gap: var(--wa-space-s); +} +.field wa-textarea.hex::part(textarea) { + font-family: var(--wa-font-family-code); +} +.field-heading { + display: flex; align-items: center; gap: var(--wa-space-xs); - font-size: var(--wa-font-size-s); + min-width: 0; +} +.field-heading .field-label { + flex: 1; +} +.remove-item { + flex-shrink: 0; color: var(--wa-color-text-quiet); } .subform { @@ -319,6 +388,19 @@ func-card.highlight { flex-direction: column; gap: var(--wa-space-s); } +/* Short fields flow side by side, e.g. in records and argument lists; the others take a row of their own. */ +.flow { + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + column-gap: var(--wa-space-m); +} +.flow > .field { + max-width: 100%; +} +.flow > .field:not(.short) { + flex-basis: 100%; +} .subform.nested { margin-top: var(--wa-space-2xs); padding-left: var(--wa-space-m); @@ -329,7 +411,7 @@ func-card.highlight { font-style: italic; } .reject { - border-color: var(--wa-color-danger-border-loud) !important; + --wa-form-control-border-color: var(--wa-color-danger-border-loud); } .field-error { font-size: var(--wa-font-size-xs); diff --git a/src/wit.ts b/src/wit.ts index f2ece46..0d8f2a7 100644 --- a/src/wit.ts +++ b/src/wit.ts @@ -310,8 +310,9 @@ export class OwnedClass extends Type { get resource(): ResourceClass { return this._ty.get_type() as ResourceClass; } + // As in WIT, where an owned handle is just the resource name. get name(): string { - return `own<${resourceName(this._ty)}>`; + return resourceName(this._ty); } } export class BorrowClass extends Type { diff --git a/tests/e2e/dialog.spec.ts b/tests/e2e/dialog.spec.ts index 3f1f500..bcc6c86 100644 --- a/tests/e2e/dialog.spec.ts +++ b/tests/e2e/dialog.spec.ts @@ -1,5 +1,7 @@ import { expect, Page, test } from '@playwright/test'; -import { answerImport, applyImports, arg, call, callEntry, expectCall, funcCard, importPrompt, loadExample, row } from './helpers'; +import { + answerImport, applyImports, arg, call, callEntry, choices, choose, chosen, expectCall, funcCard, importPrompt, loadExample, picker, row, +} from './helpers'; // Loads the resource example with `stub` as the implementation of its `imports` interface, // which defines the host resource `Y`. @@ -15,8 +17,8 @@ const s32 = (value: string) => async (prompt: ReturnType) = await prompt.locator('input').fill(value); }; const newMock = async (prompt: ReturnType) => { - await expect(prompt.locator('.sig-type').first()).toHaveText('own'); - await prompt.locator('select').selectOption({ label: 'New mock Y' }); + await expect(prompt.locator('.sig-type').first()).toHaveText('Y'); + await choose(picker(prompt), { label: 'New mock Y' }); }; test('a missing host resource is mocked, and its calls ask for their results', async ({ page }) => { @@ -54,9 +56,9 @@ test('a missing host resource is mocked, and its calls ask for their results', a test('an import returns instances the host owns, not those the guest holds', async ({ page }) => { await loadResource(page); await call(funcCard(page, 'testImports')); - const returns = (choices: string[], pick: string) => async (prompt: ReturnType) => { - await expect(prompt.locator('select option:not([disabled])')).toHaveText(choices); - await prompt.locator('select').selectOption({ label: pick }); + const returns = (offered: string[], pick: string) => async (prompt: ReturnType) => { + await expect(choices(picker(prompt))).toHaveText(offered); + await choose(picker(prompt), { label: pick }); }; await answerImport(page, '#borrowed-host-y-1.getA()', s32('10')); await answerImport(page, '#borrowed-host-y-1.getA()', s32('20')); @@ -110,28 +112,48 @@ test('mock host resources stand in for the arguments a host would create', async // WASI HTTP has no constructors for these; the host creates them for each request. With no // live instances, a new mock is the only choice, so it is selected. const handle = funcCard(page, 'handle'); - await expect(arg(handle, 0).locator('select')).toHaveValue('+mock'); - await expect(arg(handle, 1).locator('select option:checked')).toHaveText('New mock ResponseOutparam'); + await expect(picker(arg(handle, 0))).toHaveJSProperty('value', '+mock'); + await expect(chosen(picker(arg(handle, 1)))).toHaveText('New mock ResponseOutparam'); await call(handle); // Their methods ask, as do the mocks they return. const selectMock = (resource: string) => async (prompt: ReturnType) => { - await prompt.locator('select').first().selectOption('ok'); - await prompt.locator('select').nth(1).selectOption({ label: `New mock ${resource}` }); + await choose(picker(prompt), 'ok'); + await choose(picker(prompt, 1), { label: `New mock ${resource}` }); }; await answerImport(page, '#borrowed-host-incomingrequest-1.headers()', async (prompt) => { - await expect(prompt.locator('select option:checked')).toHaveText('New mock Fields'); + await expect(chosen(picker(prompt))).toHaveText('New mock Fields'); }); + // Items are added and removed one by one. A `list` is typed as text, sent as UTF-8, or as hex. await answerImport(page, '#borrowed-host-fields-3.entries()', async (prompt) => { - await prompt.locator('input.length').fill('0'); - await prompt.locator('input.length').press('Tab'); + const add = prompt.locator('wa-button', { hasText: 'Add item' }); + await add.click(); + await add.click(); + await prompt.locator('.remove-item').nth(1).click(); + await expect(prompt.locator('.remove-item')).toHaveCount(1); + await prompt.locator('wa-input input').fill('x-id'); + const bytes = prompt.locator('wa-textarea textarea'); + await bytes.fill('hé'); + await expect(prompt.locator('.bytes-mode')).toContainText('3 bytes'); + await choose(picker(prompt), 'hex'); + await expect(bytes).toHaveValue('68 c3 a9'); + await bytes.fill('68 c3 zz'); + await bytes.blur(); + await expect(prompt.locator('.field-error')).toHaveText('Expected hex bytes, e.g. "48 69"'); + // Bytes that aren't UTF-8 can't be shown as text, so they stay hex. + await bytes.fill('68 69 ff'); + await choose(picker(prompt), 'text'); + await expect(prompt.locator('.field-error')).toHaveText("These bytes aren't valid UTF-8 text"); + await expect(chosen(picker(prompt))).toHaveText('hex'); + await bytes.fill('0x68,0x69'); }); - await answerImport(page, '#borrowed-host-incomingrequest-1.method()', (prompt) => prompt.locator('select').selectOption('get').then(() => {})); + await expect(page.locator('.log-import').filter({ hasText: 'entries()' }).locator('.log-import-output code')).toHaveText('[("x-id", [104, 105])]'); + await answerImport(page, '#borrowed-host-incomingrequest-1.method()', (prompt) => choose(picker(prompt), 'get')); await answerImport(page, '#borrowed-host-incomingrequest-1.scheme()'); await answerImport(page, '#borrowed-host-incomingrequest-1.authority()'); await answerImport(page, '#borrowed-host-incomingrequest-1.pathWithQuery()', async (prompt) => { - await prompt.locator('input[type=checkbox]').check(); - await prompt.locator('input:not([type=checkbox])').fill('/'); + await choose(picker(prompt), 'some'); + await prompt.locator('wa-input input').fill('/'); }); await answerImport(page, '#borrowed-host-incomingrequest-1.consume()', selectMock('IncomingBody')); await answerImport(page, '#borrowed-host-incomingbody-4.stream()', selectMock('InputStream')); diff --git a/tests/e2e/examples.spec.ts b/tests/e2e/examples.spec.ts index 1a08f26..405b7a2 100644 --- a/tests/e2e/examples.spec.ts +++ b/tests/e2e/examples.spec.ts @@ -1,7 +1,8 @@ import path from 'node:path'; import { expect, test } from '@playwright/test'; import { - answerImport, applyImports, arg, call, callEntries, callEntry, expectCall, funcCard, importPrompt, loadExample, resource, row, + answerImport, applyImports, arg, call, callEntries, callEntry, choices, choose, expectCall, funcCard, importPrompt, loadExample, + openImports, picker, resource, row, streamEntries, } from './helpers'; @@ -32,14 +33,14 @@ test('a browser without JSPI gets a message instead of the app', async ({ page } }); test('calculator: imports are answered in the console, or implemented in JavaScript', async ({ page }) => { - // It instantiates right away; the imports card is collapsed. + // It instantiates right away, with no imports in JavaScript yet. await loadExample(page, 'calculator.wasm'); const imports = page.locator('#importsCard'); - await expect(imports).not.toHaveAttribute('open'); + await expect(page.locator('#importsDialog')).not.toHaveAttribute('open'); await expect(imports.locator('.imports-title wa-tag')).toHaveCount(0); const add = async () => { const card = funcCard(page, 'evalExpression'); - await arg(card, 0).locator('select').selectOption('add'); + await choose(picker(arg(card, 0)), 'add'); await arg(card, 1).locator('input').fill('2'); await arg(card, 2).locator('input').fill('3'); await call(card); @@ -48,15 +49,15 @@ test('calculator: imports are answered in the console, or implemented in JavaScr await answerImport(page, 'docs:adder/add#add(2, 3)', (prompt) => prompt.locator('input').fill('5')); await expectCall(page, 'evalExpression(add, 2, 3)', 'ok', '5'); - // Expanding the card shows example code, which takes effect once applied. - await imports.locator('.card-title').click(); - const stub = imports.locator('wa-textarea[label="docs:adder/add"]'); + // The dialog shows example code, which takes effect once applied. + const dialog = await openImports(page); + const stub = dialog.locator('wa-textarea[label="docs:adder/add"]'); await expect(stub).toHaveJSProperty('value', 'export function add(a, b) { return a + b }'); - await expect(imports.locator('.imports-pending')).toHaveText('Not applied yet'); - await imports.locator('#applyImportsButton').click(); - await expect(imports).not.toHaveAttribute('open'); + await expect(dialog.locator('.imports-pending')).toHaveText('Not applied yet'); + await dialog.locator('#applyImportsButton').click(); + await expect(dialog).not.toHaveAttribute('open'); await expect(imports.locator('.imports-title wa-tag')).toHaveText('1 in JavaScript'); - await expect(imports.locator('.imports-pending')).toHaveCount(0); + await expect(dialog.locator('.imports-pending')).toHaveCount(0); await add(); await expect(callEntries(page).last().locator('.log-import')).toHaveCount(0); await expectCall(page, 'evalExpression(add, 2, 3)', 'ok', '5'); @@ -64,6 +65,21 @@ test('calculator: imports are answered in the console, or implemented in JavaScr await expect(page.locator('.resource-pane')).toBeHidden(); }); +test('calculator: imports that fail to apply keep the dialog open, with the error', async ({ page }) => { + await loadExample(page, 'calculator.wasm'); + const dialog = await openImports(page); + const stub = dialog.locator('wa-textarea[label="docs:adder/add"] textarea'); + await stub.fill('export function add(a, b) {'); + await dialog.locator('#applyImportsButton').click(); + await expect(dialog.locator('wa-callout[variant="danger"]')).toContainText('Instantiation failed'); + await expect(dialog).toHaveAttribute('open'); + // Fixing the code and applying again closes it. + await stub.fill('export function add(a, b) { return a + b }'); + await dialog.locator('#applyImportsButton').click(); + await expect(dialog).not.toHaveAttribute('open'); + await expect(funcCard(page, 'evalExpression')).toBeVisible(); +}); + test('kv-store: instantiates with its Fastly imports answered in the console', async ({ page }) => { await loadExample(page, 'kv-store.wasm'); await expect(page.locator('#importsCard .imports-title wa-badge')).toHaveText('21 interfaces'); @@ -71,7 +87,7 @@ test('kv-store: instantiates with its Fastly imports answered in the console', a await expect(page.locator('wa-details.resource', { hasText: 'Request' })).not.toHaveAttribute('open'); await expect(page.locator('.section-summary code', { hasText: 'fastly:compute/http-incoming@0.1.0' })).toBeVisible(); // `body` is `pollable` renamed by a `use` in http-body, then used again by http-incoming. - await expect(arg(funcCard(page, 'handle'), 1).locator('.field-type')).toHaveText('own'); + await expect(arg(funcCard(page, 'handle'), 1).locator('.field-type')).toHaveText('Pollable'); // Every card renders its signature; a type that failed to resolve leaves one blank. const cards = page.locator('func-card'); await expect(cards.locator('.sig-name')).toHaveCount(await cards.count()); @@ -105,11 +121,11 @@ test('kv-store: host resources created in the UI are passed to handle', async ({ await expectCall(page, 'Pollable.newReady()', 'ok', '#host-pollable-2'); const setMethod = funcCard(request, 'setMethod'); - await arg(setMethod, 0).locator('select').selectOption('host-request-1'); + await choose(picker(arg(setMethod, 0)), 'host-request-1'); await arg(setMethod, 1).locator('input').fill('POST'); await call(setMethod); const getMethod = funcCard(request, 'getMethod'); - await arg(getMethod, 0).locator('select').selectOption('host-request-1'); + await choose(picker(arg(getMethod, 0)), 'host-request-1'); await arg(getMethod, 1).locator('input').fill('10'); await call(getMethod); // A `u64` is a `bigint`, shown as a number. @@ -117,12 +133,12 @@ test('kv-store: host resources created in the UI are passed to handle', async ({ // The guest accepts them; imports the stubs leave out ask for their results. const handle = funcCard(page, 'handle'); - await arg(handle, 0).locator('select').selectOption('host-request-1'); - await arg(handle, 1).locator('select').selectOption('host-pollable-2'); + await choose(picker(arg(handle, 0)), 'host-request-1'); + await choose(picker(arg(handle, 1)), 'host-pollable-2'); await call(handle); await answerImport(page, 'fastly:private/privileged-kv-store-service#get()', async (prompt) => { - await prompt.locator('select').first().selectOption('ok'); - await prompt.locator('select').nth(1).selectOption({ label: 'New mock Service' }); + await choose(picker(prompt), 'ok'); + await choose(picker(prompt, 1), { label: 'New mock Service' }); }); await expect(row(page, 'host-service-3').locator('.instance-type')).toHaveText('Service · mock'); // The guest goes on to the next import; failing it fails the call. @@ -134,12 +150,12 @@ test('kv-store: host resources created in the UI are passed to handle', async ({ test('calculator: rejects invalid numbers without calling', async ({ page }) => { await loadExample(page, 'calculator.wasm'); const card = funcCard(page, 'evalExpression'); - await arg(card, 0).locator('select').selectOption('add'); - await arg(card, 1).locator('input').fill('abc'); + await choose(picker(arg(card, 0)), 'add'); + await arg(card, 1).locator('input').fill('1.5'); await arg(card, 2).locator('input').fill('4294967296'); await call(card); await expect(card.locator('.field-error')).toHaveText([ - 'Expected an integer, got "abc"', + 'Expected an integer, got "1.5"', 'Out of range for u32: [0, 4294967295]', ]); await expect(callEntries(page)).toHaveCount(0); @@ -164,7 +180,7 @@ test('calculator: Random fills empty fields and keeps typed ones', async ({ page await expect(callEntries(page).locator(':scope > .log-call code')) .toHaveText([/^evalExpression\(add, 5, \d+\)$/, /^evalExpression\(add, 5, \d+\)$/]); // The generated values are shown in the form, marked as generated. - await expect(arg(card, 2).locator('input')).toHaveClass(/\bauto\b/); + await expect(arg(card, 2).locator('wa-number-input')).toHaveClass(/\bauto\b/); await expect(arg(card, 2).locator('input')).not.toHaveValue(''); }); @@ -179,7 +195,7 @@ test.describe('variants', () => { await expect(card.locator('.field-error')).toHaveText('Select a case'); await expect(callEntries(page)).toHaveCount(0); - await arg(card, 0).locator('select').selectOption('b'); + await choose(picker(arg(card, 0)), 'b'); await call(card); await expectCall(page, 'roundtripEnum(b)', 'ok', 'b'); }); @@ -207,8 +223,8 @@ test.describe('variants', () => { test('a trap shows the stderr output and disables the instance', async ({ page }) => { // The test component asserts on its arguments; these values trap. const card = funcCard(page, 'variantEnums'); - await arg(card, 1).locator('select').selectOption('ok'); - await arg(card, 2).locator('select').selectOption('a'); + await choose(picker(arg(card, 1)), 'ok'); + await choose(picker(arg(card, 2)), 'a'); await call(card); await expectCall(page, 'variantEnums(false, ok, a)', 'error', /^wasm trap/); await expect(streamEntries(page, 'stderr').last()).toContainText('Assertion failed'); @@ -224,7 +240,7 @@ test('wasi-http: renders the handler and its resources', async ({ page }) => { await expect(page.locator('func-card').filter({ hasNot: methodTag })).toHaveCount(2); await expect(funcCard(page, 'handle')).toBeVisible(); // Methods take the instance as `self`: a live one, or a new mock of the host resource. - await expect(arg(funcCard(page, 'pathWithQuery'), 0).locator('option:not([disabled])')).toHaveText(['New mock IncomingRequest']); + await expect(choices(picker(arg(funcCard(page, 'pathWithQuery'), 0)))).toHaveText(['New mock IncomingRequest']); await expect(page.locator('.section-summary code', { hasText: 'ResponseOutparam' })).toBeVisible(); }); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 9af2ff0..8096c7c 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -22,16 +22,21 @@ export async function loadExample(page: Page, file: string) { // Implements imports in JavaScript, keyed by package, and restarts the component with them. export async function applyImports(page: Page, code: Record) { - const card = page.locator('#importsCard'); - if ((await card.getAttribute('open')) === null) { - await card.locator('.card-title').click(); - } + const dialog = await openImports(page); for (const [pkg, source] of Object.entries(code)) { - await card.locator(`wa-textarea[label="${pkg}"] textarea`).fill(source); + await dialog.locator(`wa-textarea[label="${pkg}"] textarea`).fill(source); } - await card.locator('#applyImportsButton').click(); - // The card collapses once the new instance is ready. - await expect(card).not.toHaveAttribute('open'); + await dialog.locator('#applyImportsButton').click(); + // The dialog closes once the new instance is ready. + await expect(dialog).not.toHaveAttribute('open'); +} + +// Opens the dialog implementing imports in JavaScript. +export async function openImports(page: Page): Promise { + const dialog = page.locator('#importsDialog'); + await page.locator('#editImportsButton').click(); + await expect(dialog).toHaveAttribute('open'); + return dialog; } // The card of the exported function `name`, e.g. `add` or `constructor`. @@ -44,6 +49,33 @@ export function arg(card: Locator, i: number): Locator { return card.locator('.func-args > .field').nth(i); } +// The `i`-th case or instance picker in `scope`: a row of buttons, or a dropdown for many choices. +export function picker(scope: Locator, i = 0): Locator { + return scope.locator('wa-radio-group, wa-select').nth(i); +} + +// Picks the choice with the value `choice`, e.g. a case or an instance id, or the one labeled `label`. +export async function choose(picker: Locator, choice: string | { label: string }) { + const buttons = await picker.evaluate((e) => e.localName === 'wa-radio-group'); + const item = buttons ? 'wa-radio' : 'wa-option'; + if (!buttons) { + await picker.click(); + } + await (typeof choice === 'string' + ? picker.locator(`${item}[value="${choice}"]`) + : picker.getByRole(buttons ? 'radio' : 'option', { name: choice.label, exact: true })).click(); + await expect(chosen(picker)).toHaveCount(1); +} + +// The choices a picker offers, and the one picked. +export function choices(picker: Locator): Locator { + return picker.locator('wa-radio:not([disabled]), wa-option:not([disabled])'); +} + +export function chosen(picker: Locator): Locator { + return picker.locator('wa-radio:state(checked), wa-option:state(selected)'); +} + export async function call(card: Locator, button: 'Call' | 'Random' = 'Call') { await card.locator('wa-button', { hasText: button }).click(); } diff --git a/tests/e2e/resources.spec.ts b/tests/e2e/resources.spec.ts index 44507a7..fba1567 100644 --- a/tests/e2e/resources.spec.ts +++ b/tests/e2e/resources.spec.ts @@ -1,5 +1,7 @@ import { expect, Page, test } from '@playwright/test'; -import { applyImports, arg, call, callEntries, expectCall, fate, funcCard, loadExample, resource, row } from './helpers'; +import { + applyImports, arg, call, callEntries, choices, choose, expectCall, fate, funcCard, loadExample, picker, resource, row, +} from './helpers'; test.beforeEach(async ({ page }) => { await loadExample(page, 'resource.wasm'); @@ -28,9 +30,9 @@ test('owned arguments are moved and returned resources take their handle index', // The picker lists live instances only. const add = funcCard(resource(page, 'X'), 'add'); - const picker = arg(add, 0).locator('select'); - await expect(picker.locator('option:not([disabled])')).toHaveText(['#x-1']); - await picker.selectOption('x-1'); + const instances = picker(arg(add, 0)); + await expect(choices(instances)).toHaveText(['#x-1']); + await choose(instances, 'x-1'); await arg(add, 1).locator('input').fill('1'); await call(add); @@ -44,12 +46,12 @@ test('owned arguments are moved and returned resources take their handle index', // Methods take the instance as `self`. const getA = funcCard(resource(page, 'X'), 'getA'); - await arg(getA, 0).locator('select').selectOption('x-1'); + await choose(picker(arg(getA, 0)), 'x-1'); await call(getA); await expectCall(page, '#borrowed-x-1.getA()', 'ok', '6'); const consume = worldFunc(page, 'consume'); - await arg(consume, 0).locator('select').selectOption('x-1'); + await choose(picker(arg(consume, 0)), 'x-1'); await call(consume); await expectCall(page, 'consume(#x-1)', 'ok', ''); await expect(row(page, 'x-1')).toHaveClass(/\bdead\b/); @@ -69,14 +71,14 @@ test('borrowed arguments stay live, and Drop runs the destructor', async ({ page await expect(page.locator('resource-table tr.resource-row')).toHaveCount(2); const add = worldFunc(page, 'add'); - await arg(add, 0).locator('select').selectOption('z-1'); - await arg(add, 1).locator('select').selectOption('z-2'); + await choose(picker(arg(add, 0)), 'z-1'); + await choose(picker(arg(add, 1)), 'z-2'); await call(add); await expectCall(page, 'add(#borrowed-z-1, #borrowed-z-2)', 'ok', '#z-3'); await expect(row(page, 'z-1')).not.toHaveClass(/\bdead\b/); await expect(row(page, 'z-2')).not.toHaveClass(/\bdead\b/); const getA = funcCard(resource(page, 'Z'), 'getA'); - await arg(getA, 0).locator('select').selectOption('z-3'); + await choose(picker(arg(getA, 0)), 'z-3'); await call(getA); await expectCall(page, '#borrowed-z-3.getA()', 'ok', '5'); @@ -111,7 +113,7 @@ test('a freed index is reused by any resource, and Clear freed removes freed row await page.locator('wa-dropdown-item', { hasText: 'getA' }).click(); const getA = funcCard(resource(page, 'Z'), 'getA'); await expect(getA).toBeInViewport(); - await expect(arg(getA, 0).locator('select')).toHaveValue('z-1'); + await expect(picker(arg(getA, 0))).toHaveJSProperty('value', 'z-1'); await call(getA); await expectCall(page, '#borrowed-z-1.getA()', 'ok', '2');