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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 38 additions & 16 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -211,23 +212,47 @@ export class ComponentApp extends LitElement {
</wa-split-panel>`;
}

// 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`<wa-details id="importsCard" class="imports-card" .open=${s.status === 'failed'}>
<div slot="summary" class="card-title">
const pending = s.component.customImports.some((pkg) => (s.importCode[pkg] ?? '') !== (s.appliedCode[pkg] ?? ''));
return html`<div id="importsCard" class="imports-card">
<div class="card-title">
<span class="imports-title">
Imports
<wa-badge variant="neutral" appearance="filled" pill>${count} interface${count === 1 ? '' : 's'}</wa-badge>
${implemented > 0 ? html`<wa-tag size="s" variant="brand">${implemented} in JavaScript</wa-tag>` : nothing}
</span>
<span class="muted">
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.
</span>
</div>
<wa-button id="editImportsButton" appearance="outlined" size="s" @click=${() => (this.editingImports = true)}>
<wa-icon slot="start" name="code"></wa-icon>
Implement in JavaScript
</wa-button>
</div>
<wa-dialog
id="importsDialog"
class="imports-dialog"
label="Implement imports in JavaScript"
.open=${this.editingImports}
@wa-after-hide=${(e: Event) => {
if (e.target === e.currentTarget) {
this.editingImports = false;
}
}}
>
<p class="muted imports-help">
Each interface is an ES module exporting its functions and resource classes. The functions
you leave out ask for their results in the console.
</p>
${s.status === 'failed'
? html`<wa-callout variant="danger"><strong>Instantiation failed</strong><br />${errorMessage(s.error)}</wa-callout>`
: nothing}
<div class="imports">
${s.component.customImports.map((pkg) => html`<wa-textarea
class="code-editor"
Expand All @@ -242,7 +267,8 @@ export class ComponentApp extends LitElement {
}}
></wa-textarea>`)}
</div>
<div class="func-actions">
<div slot="footer" class="func-actions">
${pending ? html`<span class="muted imports-pending">Not applied yet</span>` : nothing}
<wa-button
id="applyImportsButton"
variant="brand"
Expand All @@ -253,11 +279,8 @@ export class ComponentApp extends LitElement {
<wa-icon slot="start" name="rotate-right"></wa-icon>
Apply and restart
</wa-button>
${s.component.customImports.some((pkg) => (s.importCode[pkg] ?? '') !== (s.appliedCode[pkg] ?? ''))
? html`<span class="muted imports-pending">Not applied yet</span>`
: nothing}
</div>
</wa-details>`;
</wa-dialog>`;
}

private renderExportsArea(s: Session) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 });
}
}
Expand Down
13 changes: 12 additions & 1 deletion src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
77 changes: 71 additions & 6 deletions src/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>`, 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');

Expand Down Expand Up @@ -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<null, Draft> {
public visitType<T>(_t: IDL.Type<T>, _d: null): Draft {
Expand All @@ -63,8 +103,8 @@ class Init extends IDL.Visitor<null, Draft> {
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<string, IDL.Type>, _d: null): Draft {
return { kind: 'fields', fields: Object.values(fields).map(initDraft) };
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -124,7 +179,7 @@ class Randomize extends IDL.Visitor<Draft, void> {
}
public visitType<T>(_t: IDL.Type<T>, _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)));
Expand Down Expand Up @@ -163,6 +218,12 @@ class Randomize extends IDL.Visitor<Draft, void> {
}
}
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)}`);
Expand Down Expand Up @@ -196,7 +257,7 @@ class Randomize extends IDL.Visitor<Draft, void> {
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;
Expand Down Expand Up @@ -303,6 +364,10 @@ class Parse extends IDL.Visitor<Draft, any> {
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<u8>`.
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<string, IDL.Type>, d: Draft): any {
Expand Down
Loading
Loading