564 lines
20 KiB
TypeScript
564 lines
20 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
vi.mock('@/infra/config/app.config.js', () => {
|
|
const c = {
|
|
fly: {
|
|
enabled: true,
|
|
apiToken: 'test-token',
|
|
org: 'test-org',
|
|
domain: 'compute.test.dev',
|
|
},
|
|
};
|
|
return {
|
|
config: c,
|
|
appConfig: c,
|
|
};
|
|
});
|
|
|
|
vi.mock('@/utils/logger.js', () => ({
|
|
default: { error: vi.fn(), info: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
|
}));
|
|
|
|
import { FlyProvider } from '@/providers/compute/fly.provider.js';
|
|
import { MachineGoneError } from '@/providers/compute/compute.provider.js';
|
|
|
|
const FLY_API_BASE = 'https://api.machines.dev/v1';
|
|
const FLY_GRAPHQL_ENDPOINT = 'https://api.fly.io/graphql';
|
|
|
|
const graphqlOkResponse = () => ({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: { allocateIpAddress: { ipAddress: { address: '66.241.125.89', type: 'v4' } } },
|
|
}),
|
|
});
|
|
|
|
describe('FlyProvider', () => {
|
|
let provider: FlyProvider;
|
|
|
|
beforeEach(() => {
|
|
provider = FlyProvider.getInstance();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('isConfigured() returns true when config is set', () => {
|
|
expect(provider.isConfigured()).toBe(true);
|
|
});
|
|
|
|
describe('createApp', () => {
|
|
it('calls correct URL with correct body and allocates IPs (3 fetches total)', async () => {
|
|
const mockFetch = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve('') })
|
|
.mockResolvedValueOnce(graphqlOkResponse())
|
|
.mockResolvedValueOnce(graphqlOkResponse());
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
const result = await provider.createApp({
|
|
name: 'my-app',
|
|
network: 'default',
|
|
org: 'test-org',
|
|
});
|
|
|
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
|
|
// First call: REST app creation
|
|
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
1,
|
|
`${FLY_API_BASE}/apps`,
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
app_name: 'my-app',
|
|
org_slug: 'test-org',
|
|
network: 'default',
|
|
}),
|
|
})
|
|
);
|
|
|
|
// Second call: shared_v4 GraphQL mutation
|
|
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
2,
|
|
FLY_GRAPHQL_ENDPOINT,
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
const body2 = JSON.parse(mockFetch.mock.calls[1][1].body);
|
|
expect(body2.variables.input).toEqual({ appId: 'my-app', type: 'shared_v4' });
|
|
|
|
// Third call: v6 GraphQL mutation
|
|
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
3,
|
|
FLY_GRAPHQL_ENDPOINT,
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
const body3 = JSON.parse(mockFetch.mock.calls[2][1].body);
|
|
expect(body3.variables.input).toEqual({ appId: 'my-app', type: 'v6' });
|
|
|
|
expect(result).toEqual({ appId: 'my-app' });
|
|
});
|
|
|
|
it('throws on Fly API REST error', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 422,
|
|
text: () => Promise.resolve('app already exists'),
|
|
});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await expect(
|
|
provider.createApp({ name: 'my-app', network: 'default', org: 'test-org' })
|
|
).rejects.toThrow('Fly API error (422): app already exists');
|
|
});
|
|
|
|
it('throws when GraphQL allocateIpAddress returns errors', async () => {
|
|
const mockFetch = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve('') })
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ errors: [{ message: 'organization limit reached' }] }),
|
|
});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await expect(
|
|
provider.createApp({ name: 'my-app', network: 'default', org: 'test-org' })
|
|
).rejects.toThrow(/Fly GraphQL allocateIpAddress\(shared_v4\) errors/);
|
|
});
|
|
|
|
it('throws when GraphQL allocateIpAddress responds non-2xx', async () => {
|
|
const mockFetch = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve('') })
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
text: () => Promise.resolve('internal server error'),
|
|
});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await expect(
|
|
provider.createApp({ name: 'my-app', network: 'default', org: 'test-org' })
|
|
).rejects.toThrow(/Fly GraphQL allocateIpAddress\(shared_v4\) failed \(500\)/);
|
|
});
|
|
});
|
|
|
|
describe('launchMachine', () => {
|
|
it('calls correct URL, returns machineId, sets correct body', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: () => Promise.resolve(JSON.stringify({ id: 'machine-abc123' })),
|
|
});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
const result = await provider.launchMachine({
|
|
appId: 'my-app',
|
|
image: 'registry.fly.io/my-app:latest',
|
|
port: 8080,
|
|
cpu: 'shared-1x',
|
|
memory: 256,
|
|
envVars: { NODE_ENV: 'production' },
|
|
region: 'iad',
|
|
});
|
|
|
|
expect(result).toEqual({ machineId: 'machine-abc123' });
|
|
|
|
const callArgs = mockFetch.mock.calls[0];
|
|
expect(callArgs[0]).toBe(`${FLY_API_BASE}/apps/my-app/machines`);
|
|
expect(callArgs[1].method).toBe('POST');
|
|
|
|
const body = JSON.parse(callArgs[1].body);
|
|
expect(body.config.image).toBe('registry.fly.io/my-app:latest');
|
|
expect(body.config.env).toEqual({ NODE_ENV: 'production' });
|
|
expect(body.config.guest).toEqual({ cpu_kind: 'shared', cpus: 1, memory_mb: 256 });
|
|
expect(body.config.services[0].internal_port).toBe(8080);
|
|
expect(body.config.services[0].protocol).toBe('tcp');
|
|
expect(body.config.services[0].ports).toEqual([
|
|
{ port: 443, handlers: ['tls', 'http'] },
|
|
{ port: 80, handlers: ['http'] },
|
|
]);
|
|
// Scale-to-zero defaults — without these Fly keeps the machine warm 24/7.
|
|
// Note: the Machines API uses the short field names (`autostop`/`autostart`),
|
|
// NOT fly.toml's `auto_stop_machines`/`auto_start_machines`. The API
|
|
// silently ignores unknown fields, so the wrong names look healthy at
|
|
// request time but leave the machine always-on. Schema reference:
|
|
// https://docs.machines.dev/spec/openapi3.json (fly.MachineService).
|
|
expect(body.config.services[0].autostop).toBe('stop');
|
|
expect(body.config.services[0].autostart).toBe(true);
|
|
expect(body.config.services[0].min_machines_running).toBe(0);
|
|
expect(body.region).toBe('iad');
|
|
});
|
|
|
|
// scaleToZero: false flips the autostop block to always-on. min_machines_running
|
|
// must be 1 (not just autostop 'off') so Fly restarts the machine if it
|
|
// exits — always-on services should survive crashes, not just skip the
|
|
// idle stop.
|
|
it('scaleToZero: false sends autostop off with one machine kept running', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: () => Promise.resolve(JSON.stringify({ id: 'machine-always-on' })),
|
|
});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await provider.launchMachine({
|
|
appId: 'my-app',
|
|
image: 'registry.fly.io/my-app:latest',
|
|
port: 8080,
|
|
cpu: 'shared-1x',
|
|
memory: 256,
|
|
envVars: {},
|
|
region: 'iad',
|
|
scaleToZero: false,
|
|
});
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.config.services[0].autostop).toBe('off');
|
|
expect(body.config.services[0].autostart).toBe(true);
|
|
expect(body.config.services[0].min_machines_running).toBe(1);
|
|
});
|
|
|
|
// scaleToZero: true must be byte-identical to omitting the field — only
|
|
// an explicit `false` may change the wire format.
|
|
it('scaleToZero: true keeps the scale-to-zero defaults', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: () => Promise.resolve(JSON.stringify({ id: 'machine-stz' })),
|
|
});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await provider.launchMachine({
|
|
appId: 'my-app',
|
|
image: 'registry.fly.io/my-app:latest',
|
|
port: 8080,
|
|
cpu: 'shared-1x',
|
|
memory: 256,
|
|
envVars: {},
|
|
region: 'iad',
|
|
scaleToZero: true,
|
|
});
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.config.services[0].autostop).toBe('stop');
|
|
expect(body.config.services[0].autostart).toBe(true);
|
|
expect(body.config.services[0].min_machines_running).toBe(0);
|
|
});
|
|
|
|
// INS-271: protocol: 'tcp' switches the edge-handler shape from the
|
|
// HTTP-terminating 443/80 pair to a single direct-passthrough port that
|
|
// matches internal_port with empty L7 handlers. Without this, raw TCP
|
|
// services (Redis, the Postgres wire protocol, etc.) wedge behind the
|
|
// Fly anycast HTTP proxy.
|
|
it('protocol: tcp sets services[].ports to a single passthrough entry with empty handlers', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: () => Promise.resolve(JSON.stringify({ id: 'machine-tcp' })),
|
|
});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await provider.launchMachine({
|
|
appId: 'my-redis',
|
|
image: 'redis:7',
|
|
port: 6379,
|
|
cpu: 'shared-1x',
|
|
memory: 256,
|
|
envVars: {},
|
|
region: 'iad',
|
|
protocol: 'tcp',
|
|
});
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.config.services[0].internal_port).toBe(6379);
|
|
// Single port = internal port, no TLS/HTTP handlers — bytes pass through.
|
|
expect(body.config.services[0].ports).toEqual([{ port: 6379, handlers: [] }]);
|
|
// Fly's services[].protocol stays 'tcp' (L4 protocol) regardless of
|
|
// edge mode — that's a Fly API constant, not our protocol field.
|
|
expect(body.config.services[0].protocol).toBe('tcp');
|
|
});
|
|
|
|
// Back-compat — omitting `protocol` is the same as passing 'http'. We pin
|
|
// both call shapes so a future refactor that flips the default doesn't
|
|
// silently change the wire format for existing HTTP deploys.
|
|
it('omitting protocol defaults to http edge handlers (443 TLS + 80 plain)', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: () => Promise.resolve(JSON.stringify({ id: 'machine-http' })),
|
|
});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await provider.launchMachine({
|
|
appId: 'my-api',
|
|
image: 'node:20',
|
|
port: 8080,
|
|
cpu: 'shared-1x',
|
|
memory: 256,
|
|
envVars: {},
|
|
region: 'iad',
|
|
});
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.config.services[0].ports).toEqual([
|
|
{ port: 443, handlers: ['tls', 'http'] },
|
|
{ port: 80, handlers: ['http'] },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('stopMachine', () => {
|
|
it('calls POST to stop endpoint', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: () => Promise.resolve(''),
|
|
});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await provider.stopMachine('my-app', 'machine-123');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
`${FLY_API_BASE}/apps/my-app/machines/machine-123/stop`,
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('destroyMachine', () => {
|
|
it('calls DELETE to machine endpoint', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: () => Promise.resolve(''),
|
|
});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await provider.destroyMachine('my-app', 'machine-123');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
`${FLY_API_BASE}/apps/my-app/machines/machine-123?force=true`,
|
|
expect.objectContaining({ method: 'DELETE' })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('listMachines', () => {
|
|
it('calls GET on machines endpoint and returns array', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: () =>
|
|
Promise.resolve(
|
|
JSON.stringify([
|
|
{ id: 'machine-1', state: 'started', region: 'iad' },
|
|
{ id: 'machine-2', state: 'stopped', region: 'iad' },
|
|
])
|
|
),
|
|
});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
const result = await provider.listMachines('my-app');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
`${FLY_API_BASE}/apps/my-app/machines`,
|
|
expect.objectContaining({ headers: expect.any(Object) })
|
|
);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].id).toBe('machine-1');
|
|
expect(result[1].state).toBe('stopped');
|
|
});
|
|
});
|
|
|
|
describe('destroyApp', () => {
|
|
it('calls DELETE to app endpoint', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
text: () => Promise.resolve(''),
|
|
});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await provider.destroyApp('my-app');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
`${FLY_API_BASE}/apps/my-app`,
|
|
expect.objectContaining({ method: 'DELETE' })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getLogs', () => {
|
|
const okText = (raw: string) => ({ ok: true, text: () => Promise.resolve(raw) });
|
|
|
|
it('hits the api.fly.io logs host with the FlyV1 scheme, scoped to the machine', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue(okText('{"data":[],"meta":{}}'));
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await provider.getLogs('my-app', 'machine-123');
|
|
|
|
const [calledUrl, init] = mockFetch.mock.calls[0];
|
|
expect(calledUrl).toContain('https://api.fly.io/api/v1/apps/my-app/logs');
|
|
// Scopes to this service's machine via the `instance` query param.
|
|
expect(calledUrl).toContain('instance=machine-123');
|
|
// Logs use Fly's macaroon scheme, NOT Bearer (which 401s on this host).
|
|
expect((init.headers as Record<string, string>).Authorization).toBe('FlyV1 test-token');
|
|
});
|
|
|
|
it('normalizes RFC3339 timestamps to epoch ms', async () => {
|
|
const raw =
|
|
'{"data":[{"attributes":{"timestamp":"2026-06-04T21:25:05.000Z",' +
|
|
'"message":"hello from container","instance":"machine-123","region":"iad"}}],"meta":{}}';
|
|
const mockFetch = vi.fn().mockResolvedValue(okText(raw));
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
const result = await provider.getLogs('my-app', 'machine-123');
|
|
|
|
expect(result.lines).toEqual([
|
|
{
|
|
timestamp: Date.parse('2026-06-04T21:25:05.000Z'),
|
|
message: 'hello from container',
|
|
instance: 'machine-123',
|
|
region: 'iad',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('returns next_token at full precision (it exceeds Number.MAX_SAFE_INTEGER)', async () => {
|
|
// A bare JSON number here would round to ...890600 under JSON.parse;
|
|
// the provider must preserve every digit by reading the raw text.
|
|
const raw = '{"data":[],"meta":{"next_token":1780608313161890637}}';
|
|
const mockFetch = vi.fn().mockResolvedValue(okText(raw));
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
const result = await provider.getLogs('my-app', 'machine-123');
|
|
|
|
expect(result.nextToken).toBe('1780608313161890637');
|
|
});
|
|
|
|
it('forwards the requested limit as a Fly query param', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue(okText('{"data":[],"meta":{}}'));
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await provider.getLogs('my-app', 'machine-123', { limit: 200 });
|
|
|
|
expect(mockFetch.mock.calls[0][0]).toContain('limit=200');
|
|
});
|
|
|
|
it('falls back to the parsed string cursor for a non-numeric next_token', async () => {
|
|
const raw = '{"data":[],"meta":{"next_token":"abc-cursor"}}';
|
|
const mockFetch = vi.fn().mockResolvedValue(okText(raw));
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
const result = await provider.getLogs('my-app', 'machine-123');
|
|
|
|
expect(result.nextToken).toBe('abc-cursor');
|
|
});
|
|
|
|
it('times out the upstream Fly request (passes an abort signal)', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue(okText('{"data":[],"meta":{}}'));
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await provider.getLogs('my-app', 'machine-123');
|
|
|
|
expect(mockFetch.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
|
|
});
|
|
|
|
it('forwards the nextToken cursor as the Fly next_token query param', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue(okText('{"data":[],"meta":{}}'));
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await provider.getLogs('my-app', 'machine-123', { nextToken: 'cursor-abc' });
|
|
|
|
expect(mockFetch.mock.calls[0][0]).toContain('next_token=cursor-abc');
|
|
});
|
|
|
|
it('throws on a non-2xx logs response', async () => {
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 401,
|
|
text: () => Promise.resolve('Unauthorized'),
|
|
});
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await expect(provider.getLogs('my-app', 'machine-123')).rejects.toThrow(/401/);
|
|
});
|
|
});
|
|
});
|
|
|
|
// A definitive 404 from a machine-scoped Fly call means the machine no longer
|
|
// exists (idle reaping, out-of-band delete). The provider must translate it
|
|
// into MachineGoneError so the service layer heals stale DB rows; transient
|
|
// errors pass through, and startMachine must fail fast instead of polling the
|
|
// dead machine for 30s.
|
|
describe('FlyProvider machine-gone translation', () => {
|
|
let provider: FlyProvider;
|
|
|
|
beforeEach(() => {
|
|
provider = FlyProvider.getInstance();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const notFound = () =>
|
|
Promise.resolve({
|
|
ok: false,
|
|
status: 404,
|
|
text: async () => 'machine not found',
|
|
} as Response);
|
|
|
|
it('translates 404s on machine-scoped calls into MachineGoneError', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockImplementation(notFound));
|
|
|
|
await expect(provider.getMachineStatus('my-app', 'm1')).rejects.toBeInstanceOf(
|
|
MachineGoneError
|
|
);
|
|
await expect(provider.getEvents('my-app', 'm1')).rejects.toBeInstanceOf(MachineGoneError);
|
|
await expect(provider.stopMachine('my-app', 'm1')).rejects.toBeInstanceOf(MachineGoneError);
|
|
await expect(provider.destroyMachine('my-app', 'm1')).rejects.toBeInstanceOf(MachineGoneError);
|
|
await expect(
|
|
provider.updateMachine({
|
|
appId: 'my-app',
|
|
machineId: 'm1',
|
|
image: 'img',
|
|
port: 8080,
|
|
cpu: 'shared-1x',
|
|
memory: 256,
|
|
envVars: {},
|
|
})
|
|
).rejects.toBeInstanceOf(MachineGoneError);
|
|
});
|
|
|
|
it('startMachine fails fast on a gone machine instead of polling for 30s', async () => {
|
|
const mockFetch = vi.fn().mockImplementation(notFound);
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
await expect(provider.startMachine('my-app', 'm1')).rejects.toBeInstanceOf(MachineGoneError);
|
|
// One status poll, then immediate fail — no 1s-interval retry loop.
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does NOT translate a logs-API 404 (app-scoped — means the APP is gone)', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockImplementation(() =>
|
|
Promise.resolve({
|
|
ok: false,
|
|
status: 404,
|
|
text: async () => 'app not found',
|
|
} as Response)
|
|
)
|
|
);
|
|
|
|
const err = await provider.getLogs('my-app', 'm1').catch((e: unknown) => e);
|
|
expect(err).not.toBeInstanceOf(MachineGoneError);
|
|
expect((err as Error).message).toMatch(/404/);
|
|
});
|
|
|
|
it('does NOT translate non-404 Fly errors', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
text: async () => 'internal',
|
|
} as Response)
|
|
);
|
|
|
|
const err = await provider.getMachineStatus('my-app', 'm1').catch((e: unknown) => e);
|
|
expect(err).not.toBeInstanceOf(MachineGoneError);
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect((err as Error).message).toMatch(/500/);
|
|
});
|
|
});
|