0.4.10 โ€ข Published 10 months ago

scriptable-testlab v0.4.10

Weekly downloads
-
License
Apache-2.0
Repository
-
Last release
10 months ago

scriptable-testlab

A comprehensive testing framework for simulating the Scriptable iOS app runtime environment

npm version License Node.js Version

Introduction

scriptable-testlab is a testing framework specifically designed for the Scriptable iOS app. It provides a complete mock runtime environment that enables developers to write and run unit tests without depending on physical iOS devices. The framework strictly implements types defined in @types/scriptable-ios to ensure type safety and precise API simulation.

Core Features

  • ๐Ÿ”„ Complete Scriptable API simulation with strict type safety
  • ๐Ÿงช Comprehensive mock implementations for all Scriptable modules
  • ๐Ÿ“ฑ Device-independent testing environment with runtime simulation
  • ๐Ÿ” Type-safe development experience with full TypeScript support
  • โšก Fast and lightweight runtime based on scriptable-abstract
  • ๐Ÿ› ๏ธ Seamless Jest integration with full testing utilities
  • ๐Ÿ“Š Built-in test coverage support and mock assertions
  • ๐Ÿ”Œ Modular architecture with pluggable components

Implementation Status

Current implementation covers:

  • โœ… Core Runtime Environment
  • โœ… Device and System APIs
  • โœ… UI Components and Widgets
  • โœ… Network and Data Operations
  • โœ… File System Operations
  • โœ… Calendar and Reminders
  • โœ… Media and Images
  • โœ… Security and Keychain
  • โœ… Location Services
  • โœ… Notifications

Installation

# Using npm
npm install --save-dev scriptable-testlab

# Using yarn
yarn add -D scriptable-testlab

# Using pnpm (recommended)
pnpm add -D scriptable-testlab

Basic Usage

Runtime Setup

import {runtime} from 'scriptable-testlab';

describe('Runtime Tests', () => {
  beforeEach(() => {
    runtime.setupMocks();
    runtime.configure({
      device: {
        appearance: {
          isUsingDarkAppearance: true,
        },
      },
    });
  });

  afterEach(() => {
    runtime.clearMocks();
  });

  test('should handle system settings', () => {
    expect(Device.isUsingDarkAppearance()).toBe(true);
  });
});

Device Configuration

import {runtime} from 'scriptable-testlab';

describe('Device Tests', () => {
  beforeEach(() => {
    runtime.setupMocks();
    runtime.configure({
      device: {
        model: 'iPhone',
        systemVersion: '16.0',
        appearance: {
          isUsingDarkAppearance: true,
        },
      },
    });
  });

  afterEach(() => {
    runtime.clearMocks();
  });

  test('should configure device settings', () => {
    expect(Device.model()).toBe('iPhone');
    expect(Device.systemVersion()).toBe('16.0');
    expect(Device.isUsingDarkAppearance()).toBe(true);
  });
});

Network Operations

import {runtime} from 'scriptable-testlab';

describe('Network Tests', () => {
  let request: Request;

  beforeEach(() => {
    runtime.setupMocks();
    request = new Request('https://api.example.com');
  });

  afterEach(() => {
    runtime.clearMocks();
  });

  test('should handle basic GET request', async () => {
    const mockedRequest = request as MockedObject<MockRequest>;
    mockedRequest.loadJSON.mockImplementation(async () => {
      mockedRequest.setState({
        response: {
          statusCode: 200,
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({message: 'Success', data: {id: 1}}),
        },
      });
    });
    const _response = await request.loadJSON();
    expect(request.response?.statusCode).toBe(200);
    expect(request.response?.headers['Content-Type']).toBe('application/json');
    expect(request.response?.body).toBe(JSON.stringify({message: 'Success', data: {id: 1}}));
  });

  test('should handle POST request with custom headers', async () => {
    const mockedRequest = request as MockedObject<MockRequest>;
    request.method = 'POST';
    request.headers = {
      'Content-Type': 'application/json',
    };
    request.body = JSON.stringify({data: 'test'});

    mockedRequest.loadJSON.mockImplementation(async () => {
      mockedRequest.setState({
        response: {
          statusCode: 200,
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({success: true}),
        },
      });
    });

    const _response = await request.loadJSON();
    expect(request.response?.statusCode).toBe(200);
    expect(request.response?.headers['Content-Type']).toBe('application/json');
    expect(request.response?.body).toBe(JSON.stringify({success: true}));
  });

  test('should handle different response types', async () => {
    const mockedRequest = request as MockedObject<MockRequest>;

    // String response
    mockedRequest.loadString.mockImplementation(async () => {
      mockedRequest.setState({
        response: {
          statusCode: 200,
          headers: {
            'Content-Type': 'text/plain',
          },
          body: 'Hello World',
        },
      });
      return 'Hello World';
    });

    const textResponse = await request.loadString();
    expect(typeof textResponse).toBe('string');
    expect(textResponse).toBe('Hello World');
    expect(request.response?.headers['Content-Type']).toBe('text/plain');

    // JSON response
    mockedRequest.loadJSON.mockImplementation(async () => {
      mockedRequest.setState({
        response: {
          statusCode: 200,
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({message: 'Hello'}),
        },
      });
      return {message: 'Hello'};
    });

    const jsonResponse = await request.loadJSON();
    expect(typeof jsonResponse).toBe('object');
    expect(jsonResponse).toEqual({message: 'Hello'});
    expect(request.response?.headers['Content-Type']).toBe('application/json');
  });

  test('should handle request errors', async () => {
    const mockedRequest = request as MockedObject<MockRequest>;
    request.url = 'invalid-url';

    mockedRequest.load.mockRejectedValue(new Error('Invalid URL'));
    await expect(request.load()).rejects.toThrow('Invalid URL');
  });
});

Location Services

import {runtime} from 'scriptable-testlab';

describe('Location Tests', () => {
  beforeEach(() => {
    runtime.setupMocks();
    runtime.configure({
      location: {
        latitude: 37.7749,
        longitude: -122.4194,
        altitude: 0,
        horizontalAccuracy: 10,
        verticalAccuracy: 10,
      },
    });
  });

  afterEach(() => {
    runtime.clearMocks();
  });

  test('should handle location services', async () => {
    const location = runtime.location;
    expect(location.latitude).toBe(37.7749);
    expect(location.longitude).toBe(-122.4194);
    expect(location.altitude).toBe(0);
  });
});

File System Operations

import {runtime} from 'scriptable-testlab';

describe('FileSystem Tests', () => {
  beforeEach(() => {
    runtime.setupMocks();
    const fm = FileManager.local();
    fm.writeString('test.txt', 'Hello World');
  });

  afterEach(() => {
    runtime.clearMocks();
  });

  test('should handle file operations', () => {
    const fm = FileManager.local();
    expect(fm.readString('test.txt')).toBe('Hello World');
    expect(fm.fileExists('test.txt')).toBe(true);

    // Write new content
    fm.writeString('test.txt', 'Updated content');
    expect(fm.readString('test.txt')).toBe('Updated content');
  });
});

Notification Testing

import {runtime} from 'scriptable-testlab';

describe('Notification Tests', () => {
  beforeEach(() => {
    runtime.setupMocks();
    MockNotification.reset();
  });

  afterEach(() => {
    runtime.clearMocks();
  });

  test('should handle notifications', async () => {
    const notification = new Notification();
    notification.title = 'Test Title';
    notification.body = 'Test Body';
    notification.subtitle = 'Test Subtitle';

    await notification.schedule();

    const pending = await Notification.allPending();
    expect(pending).toHaveLength(1);
    expect(pending[0].title).toBe('Test Title');
    expect(pending[0].body).toBe('Test Body');
    expect(pending[0].subtitle).toBe('Test Subtitle');
  });
});

Calendar Integration

import {runtime} from 'scriptable-testlab';

describe('Calendar Tests', () => {
  beforeEach(() => {
    runtime.setupMocks();
    MockCalendar.clearAll();
  });

  afterEach(() => {
    runtime.clearMocks();
  });

  test('should handle calendar operations', async () => {
    // Create a calendar
    const calendars = await Calendar.forEvents();
    const calendar = calendars[0];

    // Create an event
    const event = new CalendarEvent();
    event.title = 'Test Event';
    event.notes = 'Event description';
    event.startDate = new Date('2024-01-01T10:00:00');
    event.endDate = new Date('2024-01-01T11:00:00');
    event.isAllDay = false;

    // Verify the event properties
    expect(event.title).toBe('Test Event');
    expect(event.notes).toBe('Event description');
    expect(event.isAllDay).toBe(false);
  });
});

Script Global Object

import {runtime} from 'scriptable-testlab';

describe('Script Tests', () => {
  beforeEach(() => {
    runtime.setupMocks();
  });

  afterEach(() => {
    runtime.clearMocks();
  });

  test('should handle script operations', () => {
    // Set script name
    runtime.script.setState({name: 'test-script'});
    expect(Script.name()).toBe('test-script');

    // Set shortcut output
    const shortcutData = {result: 'success', value: 42};
    Script.setShortcutOutput(shortcutData);
    expect(runtime.script.state.shortcutOutput).toEqual(shortcutData);

    // Set widget
    const widget = new ListWidget();
    widget.addText('Hello World');
    Script.setWidget(widget);
    expect(runtime.script.state.widget).toBe(widget);

    // Complete script
    expect(() => Script.complete()).not.toThrow();
  });

  test('should handle script in different contexts', () => {
    // Configure script to run in widget
    runtime.configure({
      widget: {
        widgetFamily: 'medium',
        runsInWidget: true,
      },
    });
    expect(config.runsInWidget).toBe(true);
    expect(config.widgetFamily).toBe('medium');

    // Configure script to run with Siri
    runtime.configure({
      widget: {
        runsWithSiri: true,
      },
    });
    expect(config.runsWithSiri).toBe(true);

    // Configure script to run from home screen
    runtime.configure({
      widget: {
        runsFromHomeScreen: true,
      },
    });
    expect(config.runsFromHomeScreen).toBe(true);
  });
});

Pasteboard Operations

import {runtime} from 'scriptable-testlab';

describe('Pasteboard Tests', () => {
  let mockedPasteboard: MockedObject<MockPasteboard>;

  beforeEach(() => {
    runtime.setupMocks();
    mockedPasteboard = Pasteboard as MockedObject<MockPasteboard>;
    mockedPasteboard.clear();
  });

  afterEach(() => {
    runtime.clearMocks();
  });

  test('should handle text operations', () => {
    // Copy and paste text
    const text = 'Hello Scriptable';
    Pasteboard.copy(text);
    expect(Pasteboard.paste()).toBe(text);

    // Copy and paste string
    const str = 'Test String';
    Pasteboard.copyString(str);
    expect(Pasteboard.pasteString()).toBe(str);
  });

  test('should handle image operations', () => {
    // Create a test image
    const image = new MockImage();

    // Copy and paste image
    Pasteboard.copyImage(image);
    expect(Pasteboard.pasteImage()).toBe(image);
    expect(mockedPasteboard.hasImages()).toBe(true);
  });

  test('should handle multiple items', () => {
    const url = 'https://example.com';
    const text = 'Example Text';

    // Set multiple items
    mockedPasteboard.setItems([{text}, {url}]);

    // Verify items
    expect(Pasteboard.paste()).toBe(text);
    expect(mockedPasteboard.hasURLs()).toBe(true);
    expect(mockedPasteboard.getURLs()).toContain(url);
  });
});

Safari Operations

import {runtime} from 'scriptable-testlab';

describe('Safari Tests', () => {
  let mockedSafari: MockedObject<MockSafari>;

  beforeEach(() => {
    runtime.setupMocks();
    mockedSafari = Safari as MockedObject<MockSafari>;
    mockedSafari.setState({
      currentURL: null,
      inBackground: false,
      openMethod: null,
      fullscreen: false,
    });
  });

  afterEach(() => {
    runtime.clearMocks();
  });

  test('should demonstrate basic Safari operations', () => {
    // Example 1: Open the URL in your browser
    const browserUrl = 'https://example.com';
    Safari.open(browserUrl);
    expect(mockedSafari['state']).toEqual({
      currentURL: browserUrl,
      inBackground: false,
      openMethod: 'browser',
      fullscreen: false,
    });

    // Example 2: Open URL in app (full screen)
    const appUrl = 'https://example.com/app';
    Safari.openInApp(appUrl, true);
    expect(mockedSafari['state']).toEqual({
      currentURL: appUrl,
      inBackground: false,
      openMethod: 'app',
      fullscreen: true,
    });

    // Example 3: Open URL in app (not full screen)
    const appUrl2 = 'https://example.com/app2';
    Safari.openInApp(appUrl2);
    expect(mockedSafari['state']).toEqual({
      currentURL: appUrl2,
      inBackground: false,
      openMethod: 'app',
      fullscreen: false,
    });
  });

  test('should demonstrate error handling', () => {
    // Example 4: Handling invalid URLs
    expect(() => Safari.open('invalid-url')).toThrow('Invalid URL');

    // Example 5: Handling an invalid URL protocol
    expect(() => Safari.open('ftp://example.com')).toThrow('Invalid URL scheme');
  });

  test('should demonstrate URL validation', () => {
    // Example 6: Verify a valid URL
    const validUrls = ['http://example.com', 'https://example.com', 'https://sub.domain.com/path?query=1'];

    validUrls.forEach(url => {
      expect(() => Safari.open(url)).not.toThrow();
      expect(Safari.openInApp(url)).resolves.not.toThrow();
    });
  });
});

Console and Logging

import {runtime} from 'scriptable-testlab';

describe('Console Tests', () => {
  beforeEach(() => {
    runtime.setupMocks();
    jest.spyOn(console, 'log');
    jest.spyOn(console, 'warn');
    jest.spyOn(console, 'error');
  });

  afterEach(() => {
    runtime.clearMocks();
    jest.restoreAllMocks();
  });

  test('should handle different log levels', () => {
    // Standard log
    console.log('Info message');
    expect(console.log).toHaveBeenCalledWith('Info message');

    // Warning log
    console.warn('Warning message');
    expect(console.warn).toHaveBeenCalledWith('Warning message');

    // Error log
    console.error('Error message');
    expect(console.error).toHaveBeenCalledWith('Error message');
  });

  test('should handle object logging', () => {
    const obj = {key: 'value'};
    console.log(JSON.stringify(obj));
    expect(console.log).toHaveBeenCalledWith(JSON.stringify(obj));
  });
});

Module Import

import {runtime} from 'scriptable-testlab';

describe('Module Import Tests', () => {
  beforeEach(() => {
    runtime.setupMocks();
    jest.resetModules();

    // Create mock modules
    jest.mock(
      'test-module',
      () => ({
        testFunction: () => 'test',
      }),
      {virtual: true},
    );

    jest.mock(
      'relative/path/module',
      () => ({
        relativeFunction: () => 'relative',
      }),
      {virtual: true},
    );

    jest.mock(
      'invalid-module',
      () => {
        const error = new Error('Unexpected token');
        error.name = 'SyntaxError';
        throw error;
      },
      {virtual: true},
    );
  });

  test('should handle module imports', () => {
    const module = importModule('test-module');
    expect(module).toBeDefined();
    expect(typeof (module as {testFunction: () => string}).testFunction).toBe('function');
    expect((module as {testFunction: () => string}).testFunction()).toBe('test');

    const relativeModule = importModule('relative/path/module');
    expect(relativeModule).toBeDefined();
    expect(typeof (relativeModule as {relativeFunction: () => string}).relativeFunction).toBe('function');
    expect((relativeModule as {relativeFunction: () => string}).relativeFunction()).toBe('relative');
  });

  test('should handle module import errors', () => {
    expect(() => {
      importModule('non-existent-module');
    }).toThrow('Module not found: non-existent-module');

    expect(() => {
      importModule('invalid-module');
    }).toThrow('Syntax error in module: invalid-module');
  });
});

License

This project is licensed under the Apache-2.0 License - see the LICENSE file for details.

0.4.10

10 months ago

0.4.9

10 months ago

0.4.8

10 months ago

0.4.7

10 months ago

0.4.6

10 months ago

0.4.5

10 months ago

0.4.4

10 months ago

0.4.3

10 months ago

0.4.2

10 months ago

0.4.1

10 months ago

0.4.0

10 months ago

0.3.1

10 months ago

0.3.0

10 months ago

0.2.0

10 months ago

0.1.1

11 months ago