# scriptable-testlab

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

[![npm version](https://badge.fury.io/js/scriptable-testlab.svg)](https://badge.fury.io/js/scriptable-testlab)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Node.js Version](https://img.shields.io/node/v/scriptable-testlab.svg)](https://nodejs.org)

## 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

```bash
# 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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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](LICENSE) file for details.
