0.4.10 โ€ข Published 5 months ago

scriptable-testlab v0.4.10

Weekly downloads
-
License
Apache-2.0
Repository
-
Last release
5 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

5 months ago

0.4.9

5 months ago

0.4.8

5 months ago

0.4.7

5 months ago

0.4.6

5 months ago

0.4.5

5 months ago

0.4.4

5 months ago

0.4.3

5 months ago

0.4.2

5 months ago

0.4.1

5 months ago

0.4.0

5 months ago

0.3.1

5 months ago

0.3.0

5 months ago

0.2.0

5 months ago

0.1.1

6 months ago