0.4.10 โข Published 5 months ago
scriptable-testlab v0.4.10
scriptable-testlab
A comprehensive testing framework for simulating the Scriptable iOS app runtime environment
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.