@ 261106.1 · fetching
current
#!/usr/bin/env python3
import glob
import json
import os
import platform
import shutil
import stat
import subprocess
import sys
import tempfile
import unittest

WINDOWS = sys.platform.startswith('win')
MACOS = sys.platform == 'darwin'
MACOS_ARM64 = MACOS and platform.machine() == 'arm64'

emconfig = os.path.abspath('.emscripten')
assert os.path.exists(emconfig)

if WINDOWS:
    emsdk = 'emsdk.bat'
else:
    emsdk = './emsdk'

# Utilities


def listify(x):
    if type(x) in {list, tuple}:
        return x
    return [x]


def copy_emsdk_to(targetdir):
    for filename in os.listdir('.'):
        if not filename.startswith('.') and not os.path.isdir(filename):
            shutil.copy2(filename, os.path.join(targetdir, filename))


def check_call(cmd, **kwargs):
    if type(cmd) is not list:
        cmd = cmd.split()
    print(f'running: {cmd}')
    subprocess.run(cmd, check=True, text=True, **kwargs)


def checked_call_with_output(cmd, expected=None, unexpected=None, stderr=None, env=None):
    cmd = cmd.split(' ')
    print(f'running: {cmd}')
    try:
        stdout = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=stderr, check=True, text=True, env=env).stdout
    except subprocess.CalledProcessError as e:
        print(e.stderr)
        print(e.stdout)
        raise e

    if expected:
        for x in listify(expected):
            assert x in stdout, 'expected output missing: ' + stdout + '\n[[[' + x + ']]]'
    if unexpected:
        for x in listify(unexpected):
            assert x not in stdout, 'unexpected output present: ' + stdout + '\n[[[' + x + ']]]'


def failing_call_with_output(cmd, expected, env=None):
    proc = subprocess.run(cmd.split(' '), capture_output=True, text=True, env=env)
    stdout = proc.stdout
    stderr = proc.stderr
    if WINDOWS:
        print('warning: skipping part of failing_call_with_output() due to error codes not being propagated (see #592)')
    else:
        assert proc.returncode, 'call must have failed: ' + str([stdout, '\n========\n', stderr])
    assert expected in stdout or expected in stderr, 'call did not have the expected output: {}: {}'.format(expected, str([stdout, '\n========\n', stderr]))


def hack_emsdk(marker, replacement):
    with open('emsdk.py') as f:
        src = f.read()
    assert marker in src
    src = src.replace(marker, replacement)
    name = '__test_emsdk'
    with open(name, 'w') as f:
        f.write(src)
    return name


def get_longest_path_in_dir(dirname):
    longest = ''
    for root, _dirs, files in os.walk(dirname):
        for f in files:
            fullname = os.path.join(root, f)
            if len(fullname) > len(longest):
                longest = fullname
    return longest


def remove_file(filename):
    if os.path.exists(filename):
        os.remove(filename)


def remove_tree(path):
    if not os.path.exists(path):
        return
    if WINDOWS:
        path = '\\\\?\\' + path

    def remove_readonly(func, path2, _):
        os.chmod(path2, stat.S_IRWXU)
        func(path2)

    shutil.rmtree(path, onerror=remove_readonly)


# Set up

TAGS = json.loads(open('emscripten-releases-tags.json').read())

# Tests


def do_lib_building(emcc):
    cache_building_messages = ['generating system library: ']

    def do_build(args, is_expected=None):
        unexpected = None
        expected = None
        if is_expected is True:
            expected = cache_building_messages
        elif is_expected is False:
            unexpected = cache_building_messages
        checked_call_with_output(emcc + ' hello_world.c' + args,
                                 expected=expected,
                                 unexpected=unexpected,
                                 stderr=subprocess.STDOUT)

    # The emsdk ships all system libraries so we don't expect to see any
    # cache population unless we explicly --clear-cache.
    do_build('', is_expected=False)
    check_call(emcc + ' --clear-cache')
    do_build(' -O2', is_expected=True)
    # Do another build at -O0.  In nwers SDK versions this generates
    # different libs, but not in older ones so don't assert here.
    do_build('')
    # Now verify that libs are *not* build
    do_build(' -s WASM=0', is_expected=False)
    do_build(' -O2 -s WASM=0', is_expected=False)


def run_emsdk(cmd):
    if type(cmd) is not list:
        cmd = cmd.split()
    check_call([emsdk, *cmd])


def upstream_emcc(root='.'):
    emcc = os.path.join(root, 'upstream', 'emscripten', 'emcc')
    if WINDOWS:
        if os.path.exists(emcc + '.exe'):
            return emcc + '.exe'
        else:
            return emcc + '.bat'
    return emcc


class Emsdk(unittest.TestCase):
    def setUp(self):
        run_emsdk('install latest')
        run_emsdk('activate latest')
        with open('hello_world.c', 'w') as f:
            f.write('''\
      #include <stdio.h>

      int main() {
         printf("Hello, world!\\n");
         return 0;
      }
      ''')
        self.addCleanup(remove_file, 'hello_world.c')

    def tearDown(self):
        for f in glob.glob('a.out*'):
            os.remove(f)

    def test_extrememly_long_filenames(self):
        # We have special support for filenames longer than 256 on windows. This
        # test installs emsdk in path that exceeds this limit in order to test
        # this handling.
        longpath = os.path.abspath('very_long_filename_indeed')

        additional = 140 - len(longpath)
        longpath += 'x' * additional

        remove_tree(longpath)
        self.addCleanup(remove_tree, longpath)

        os.makedirs(longpath)
        copy_emsdk_to(longpath)

        emsdk = os.path.join(longpath, 'emsdk')
        if WINDOWS:
            emsdk += '.bat'
        self.assertTrue(os.path.exists(emsdk))

        check_call([emsdk, 'install', 'latest'])
        check_call([emsdk, 'activate', 'latest'])

        # Check that emcc exists in the expected location
        emcc = upstream_emcc(longpath)
        print(emcc)
        self.assertTrue(os.path.exists(emcc))

        # Find the longest installed path and assert this is longer than 256
        longest_path = get_longest_path_in_dir(longpath)
        print(f'longest ({len(longest_path)}: {longest_path})')
        self.assertGreater(len(longest_path), 256)

        # Finally make sure we can actaully compile something
        check_call([emcc, 'hello_world.c'])

    def test_unknown_arch(self):
        env = os.environ.copy()
        env['EMSDK_ARCH'] = 'mips'
        failing_call_with_output(emsdk + ' install latest',
                                 expected='unknown machine architecture: mips',
                                 env=env)

    def test_wrong_bitness(self):
        env = os.environ.copy()
        env['EMSDK_ARCH'] = 'x86'
        failing_call_with_output(emsdk + ' install sdk-latest-64bit',
                                 expected='is only provided for 64-bit OSe',
                                 env=env)

    def test_already_installed(self):
        # Test we don't re-download unnecessarily
        checked_call_with_output(emsdk + ' install latest', expected='already installed', unexpected='Downloading:')

    def test_list(self):
        # Test we report installed tools properly. The latest version should be
        # installed, but not some random old one.
        checked_call_with_output(emsdk + ' list', expected=TAGS['aliases']['latest'] + '    INSTALLED', unexpected='1.39.15    INSTALLED:')

    def test_config_contents(self):
        print('test .emscripten contents')
        run_emsdk('install 6.0.3')
        run_emsdk('activate 6.0.3')
        with open(emconfig) as f:
            config = f.read()
        self.assertIn('upstream', config)

        # Older version of emscripten (prior to 6.0.3) don't support $CFGDIR
        self.assertIn('import os', config)
        self.assertIn('emsdk_path + ', config)
        self.assertNotIn('$CFGDIR', config)

        # For newer versions we use $CFGDIR
        version_file = os.path.join('upstream', 'emscripten', 'emscripten-version.txt')
        with open(version_file) as f:
            old_ver = f.read()
        try:
            # Hack the version file to contain 6.0.4
            with open(version_file, 'w') as f:
                f.write('6.0.4\n')
            # Re-activate 6.0.3 but with the hacked version file which makes it
            # appear to contain 6.0.4.
            run_emsdk('activate 6.0.3')
            with open(emconfig) as f:
                config = f.read()
            self.assertNotIn('import os', config)
            self.assertNotIn('emsdk_path', config)
            self.assertIn('$CFGDIR', config)
        finally:
            with open(version_file, 'w') as f:
                f.write(old_ver)
            run_emsdk('activate 6.0.3')

    def test_lib_building(self):
        print('building proper system libraries')
        do_lib_building(upstream_emcc())

    def test_redownload(self):
        print('go back to using upstream')
        run_emsdk('activate latest')

        # Test the normal tools like node don't re-download on re-install
        print('another install must re-download')
        checked_call_with_output(emsdk + ' uninstall node-24.19.0-64bit')
        checked_call_with_output(emsdk + ' install node-24.19.0-64bit', expected='Downloading:', unexpected='already installed')
        checked_call_with_output(emsdk + ' install node-24.19.0-64bit', unexpected='Downloading:', expected='already installed')

    def test_tot_upstream(self):
        print('test tot-upstream')
        run_emsdk('install tot-upstream')
        with open(emconfig) as f:
            config = f.read()
        run_emsdk('activate tot-upstream')
        self.addCleanup(remove_file, emconfig + '.old')
        with open(emconfig + '.old') as f:
            old_config = f.read()
        self.assertEqual(config, old_config)
        # TODO; test on latest as well
        check_call(upstream_emcc() + ' hello_world.c')

    def test_closure(self):
        # Specifically test with `--closure` so we know that node_modules is working
        check_call(upstream_emcc() + ' hello_world.c --closure=1')

    def test_specific_version(self):
        if MACOS_ARM64:
            self.skipTest('Old sdk versions do not have ARM64 binaries')
        print('test specific release (new, short name)')
        run_emsdk('install 1.38.33')
        print('another install, but no need for re-download')
        checked_call_with_output(emsdk + ' install 1.38.33', expected='Skipped', unexpected='Downloading:')
        run_emsdk('activate 1.38.33')

    def test_specific_version_full(self):
        if MACOS_ARM64:
            self.skipTest('Old sdk versions do not have ARM64 binaries')
        print('test specific release (new, full name)')
        run_emsdk('install sdk-1.38.33-64bit')
        run_emsdk('activate sdk-1.38.33-64bit')
        print('test specific release (new, tag name)')
        run_emsdk('install sdk-tag-1.38.33-64bit')
        run_emsdk('activate sdk-tag-1.38.33-64bit')

    def test_binaryen_from_source(self):
        if WINDOWS:
            # It takes over 30 mins to build binaryen using Visual Studio in CI
            self.skipTest('test is too slow under windows')
        run_emsdk(['install', '--build=Release', 'binaryen-main-64bit'])

    def test_no_32bit(self):
        print('test 32-bit error')
        emsdk_hacked = hack_emsdk('not is_os_64bit()', 'True')
        self.addCleanup(remove_file, emsdk_hacked)
        failing_call_with_output(f'{sys.executable} {emsdk_hacked} install latest',
                                 'this tool is only provided for 64-bit OSes')

    def test_update_no_git(self):
        print('test non-git update')

        temp_dir = tempfile.mkdtemp()
        self.addCleanup(remove_tree, temp_dir)
        copy_emsdk_to(temp_dir)

        olddir = os.getcwd()
        try:
            os.chdir(temp_dir)
            run_emsdk('update')

            print('second time')
            run_emsdk('update')
        finally:
            os.chdir(olddir)

    def test_install_arbitrary(self):
        # Test that its possible to install arbrary emscripten-releases SDKs
        run_emsdk('install 1b7f7bc6002a3ca73647f41fc10e1fac7f06f804')

        # Check that its not re-downloaded
        checked_call_with_output(emsdk + ' install 1b7f7bc6002a3ca73647f41fc10e1fac7f06f804', expected='Skipped', unexpected='Downloading:')

    def test_install_tool(self):
        # Test that its possible to install emscripten as tool instead of SDK
        checked_call_with_output(emsdk + ' install releases-77b065ace39e6ab21446e13f92897f956c80476a', unexpected='Installing SDK')

    def test_activate_missing(self):
        run_emsdk('install latest')
        expected = "error: tool is not installed and therefore cannot be activated: 'releases-13e29bd55185e3c12802bc090b4507901856b2ba-64bit'"
        failing_call_with_output(emsdk + ' activate 2.0.1', expected=expected)

    def test_keep_downloads(self):
        env = os.environ.copy()
        env['EMSDK_KEEP_DOWNLOADS'] = '1'
        # With EMSDK_KEEP_DOWNLOADS the downloading should happen on the first
        # install of 2.0.28, and again when we install 2.0.29, but not on the
        # second install of 2.0.28 because the zip should already be local.
        shutil.rmtree('downloads')
        checked_call_with_output(emsdk + ' install 3.1.54', expected='Downloading:', env=env)
        checked_call_with_output(emsdk + ' install 3.1.55', expected='Downloading:', env=env)
        checked_call_with_output(emsdk + ' install 3.1.54', expected='already downloaded, skipping', unexpected='Downloading:', env=env)

    def test_unicode_path(self):
        temp_dir = tempfile.mkdtemp(prefix='test_työpöytä_')
        self.addCleanup(remove_tree, temp_dir)
        copy_emsdk_to(temp_dir)

        olddir = os.getcwd()
        try:
            os.chdir(temp_dir)
            run_emsdk('list')
            run_emsdk('construct_env')
        finally:
            os.chdir(olddir)


if __name__ == '__main__':
    unittest.main(verbosity=2)